From 66ba32b023adc45b2e7f70fb729c59367c638d5f Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Mar 2026 20:09:41 -0500
Subject: [PATCH 001/100] feat: Per-user workflow libraries in multiuser mode
(#114)
* Add per-user workflow isolation: migration 28, service updates, router ownership checks, is_public endpoint, schema regeneration, frontend UI
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
* feat: add shared workflow checkbox to Details panel, auto-tag, gate edit/delete, fix tests
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
invokeai/app/api/routers/workflows.py | 124 ++++++-
.../app/services/shared/sqlite/sqlite_util.py | 2 +
.../migrations/migration_28.py | 45 +++
.../workflow_records/workflow_records_base.py | 16 +-
.../workflow_records_common.py | 6 +
.../workflow_records_sqlite.py | 85 ++++-
invokeai/frontend/web/openapi.json | 177 +++++++++-
invokeai/frontend/web/public/locales/en.json | 2 +
.../sidePanel/workflow/WorkflowGeneralTab.tsx | 54 ++-
.../WorkflowLibrarySideNav.tsx | 1 +
.../workflow/WorkflowLibrary/WorkflowList.tsx | 10 +
.../WorkflowLibrary/WorkflowListItem.tsx | 61 +++-
.../WorkflowLibrary/WorkflowSortControl.tsx | 3 +-
.../nodes/store/workflowLibrarySlice.ts | 15 +-
.../components/SaveWorkflowAsDialog.tsx | 25 +-
.../hooks/useCreateNewWorkflow.ts | 4 +-
.../src/services/api/endpoints/workflows.ts | 16 +
.../frontend/web/src/services/api/schema.ts | 104 +++++-
.../frontend/web/src/services/api/types.ts | 2 +-
tests/app/routers/test_workflows_multiuser.py | 334 ++++++++++++++++++
20 files changed, 1050 insertions(+), 36 deletions(-)
create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py
create mode 100644 tests/app/routers/test_workflows_multiuser.py
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 72d50a416b4..7e34660a1df 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -6,6 +6,7 @@
from fastapi.responses import FileResponse
from PIL import Image
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
@@ -33,16 +34,25 @@
},
)
async def get_workflow(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to get"),
) -> WorkflowRecordWithThumbnailDTO:
"""Gets a workflow"""
try:
- thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id)
workflow = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
- return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump())
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ is_default = workflow.workflow.meta.category is WorkflowCategory.Default
+ is_owner = workflow.user_id == current_user.user_id
+ if not (is_default or is_owner or workflow.is_public or current_user.is_admin):
+ raise HTTPException(status_code=403, detail="Not authorized to access this workflow")
+
+ thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id)
+ return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump())
+
@workflows_router.patch(
"/i/{workflow_id}",
@@ -52,9 +62,18 @@ async def get_workflow(
},
)
async def update_workflow(
+ current_user: CurrentUserOrDefault,
workflow: Workflow = Body(description="The updated workflow", embed=True),
) -> WorkflowRecordDTO:
"""Updates a workflow"""
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow.id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ if not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow)
@@ -63,9 +82,18 @@ async def update_workflow(
operation_id="delete_workflow",
)
async def delete_workflow(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to delete"),
) -> None:
"""Deletes a workflow"""
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ if not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to delete this workflow")
try:
ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id)
except WorkflowThumbnailFileNotFoundException:
@@ -82,10 +110,11 @@ async def delete_workflow(
},
)
async def create_workflow(
+ current_user: CurrentUserOrDefault,
workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True),
) -> WorkflowRecordDTO:
"""Creates a workflow"""
- return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow)
+ return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow, user_id=current_user.user_id)
@workflows_router.get(
@@ -96,6 +125,7 @@ async def create_workflow(
},
)
async def list_workflows(
+ current_user: CurrentUserOrDefault,
page: int = Query(default=0, description="The page to get"),
per_page: Optional[int] = Query(default=None, description="The number of workflows per page"),
order_by: WorkflowRecordOrderBy = Query(
@@ -106,8 +136,19 @@ async def list_workflows(
tags: Optional[list[str]] = Query(default=None, description="The tags of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
"""Gets a page of workflows"""
+ config = ApiDependencies.invoker.services.configuration
+
+ # In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows
+ user_id_filter: Optional[str] = None
+ if config.multiuser:
+ # Only filter 'user' category results by user_id when not explicitly listing public workflows
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
+
workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
workflows = ApiDependencies.invoker.services.workflow_records.get_many(
order_by=order_by,
@@ -118,6 +159,8 @@ async def list_workflows(
categories=categories,
tags=tags,
has_been_opened=has_been_opened,
+ user_id=user_id_filter,
+ is_public=is_public,
)
for workflow in workflows.items:
workflows_with_thumbnails.append(
@@ -143,15 +186,20 @@ async def list_workflows(
},
)
async def set_workflow_thumbnail(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to update"),
image: UploadFile = File(description="The image file to upload"),
):
"""Sets a workflow's thumbnail image"""
try:
- ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
if not image.content_type or not image.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
@@ -177,14 +225,19 @@ async def set_workflow_thumbnail(
},
)
async def delete_workflow_thumbnail(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to update"),
):
"""Removes a workflow's thumbnail image"""
try:
- ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
try:
ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id)
except ValueError as e:
@@ -223,37 +276,90 @@ async def get_workflow_thumbnail(
raise HTTPException(status_code=404)
+@workflows_router.patch(
+ "/i/{workflow_id}/is_public",
+ operation_id="update_workflow_is_public",
+ responses={
+ 200: {"model": WorkflowRecordDTO},
+ },
+)
+async def update_workflow_is_public(
+ current_user: CurrentUserOrDefault,
+ workflow_id: str = Path(description="The workflow to update"),
+ is_public: bool = Body(description="Whether the workflow should be shared publicly", embed=True),
+) -> WorkflowRecordDTO:
+ """Updates whether a workflow is shared publicly"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
+ return ApiDependencies.invoker.services.workflow_records.update_is_public(
+ workflow_id=workflow_id, is_public=is_public
+ )
+
+
@workflows_router.get("/tags", operation_id="get_all_tags")
async def get_all_tags(
+ current_user: CurrentUserOrDefault,
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
) -> list[str]:
"""Gets all unique tags from workflows"""
-
- return ApiDependencies.invoker.services.workflow_records.get_all_tags(categories=categories)
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser:
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
+
+ return ApiDependencies.invoker.services.workflow_records.get_all_tags(
+ categories=categories, user_id=user_id_filter, is_public=is_public
+ )
@workflows_router.get("/counts_by_tag", operation_id="get_counts_by_tag")
async def get_counts_by_tag(
+ current_user: CurrentUserOrDefault,
tags: list[str] = Query(description="The tags to get counts for"),
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
) -> dict[str, int]:
"""Counts workflows by tag"""
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser:
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
return ApiDependencies.invoker.services.workflow_records.counts_by_tag(
- tags=tags, categories=categories, has_been_opened=has_been_opened
+ tags=tags, categories=categories, has_been_opened=has_been_opened, user_id=user_id_filter, is_public=is_public
)
@workflows_router.get("/counts_by_category", operation_id="counts_by_category")
async def counts_by_category(
+ current_user: CurrentUserOrDefault,
categories: list[WorkflowCategory] = Query(description="The categories to include"),
has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
) -> dict[str, int]:
"""Counts workflows by category"""
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser:
+ has_user_category = WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
return ApiDependencies.invoker.services.workflow_records.counts_by_category(
- categories=categories, has_been_opened=has_been_opened
+ categories=categories, has_been_opened=has_been_opened, user_id=user_id_filter, is_public=is_public
)
diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py
index 645509f1dde..2478e8cdcae 100644
--- a/invokeai/app/services/shared/sqlite/sqlite_util.py
+++ b/invokeai/app/services/shared/sqlite/sqlite_util.py
@@ -30,6 +30,7 @@
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_25 import build_migration_25
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_26 import build_migration_26
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
@@ -77,6 +78,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_25(app_config=config, logger=logger))
migrator.register_migration(build_migration_26(app_config=config, logger=logger))
migrator.register_migration(build_migration_27())
+ migrator.register_migration(build_migration_28())
migrator.run_migrations()
return db
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py
new file mode 100644
index 00000000000..0cbd683ab5e
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py
@@ -0,0 +1,45 @@
+"""Migration 28: Add per-user workflow isolation columns to workflow_library.
+
+This migration adds the database columns required for multiuser workflow isolation
+to the workflow_library table:
+- user_id: the owner of the workflow (defaults to 'system' for existing workflows)
+- is_public: whether the workflow is shared with all users
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration28Callback:
+ """Migration to add user_id and is_public to the workflow_library table."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._update_workflow_library_table(cursor)
+
+ def _update_workflow_library_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add user_id and is_public columns to workflow_library table."""
+ cursor.execute("PRAGMA table_info(workflow_library);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "user_id" not in columns:
+ cursor.execute("ALTER TABLE workflow_library ADD COLUMN user_id TEXT DEFAULT 'system';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_user_id ON workflow_library(user_id);")
+
+ if "is_public" not in columns:
+ cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);")
+
+
+def build_migration_28() -> Migration:
+ """Builds the migration object for migrating from version 27 to version 28.
+
+ This migration adds per-user workflow isolation to the workflow_library table:
+ - user_id column: identifies the owner of each workflow
+ - is_public column: controls whether a workflow is shared with all users
+ """
+ return Migration(
+ from_version=27,
+ to_version=28,
+ callback=Migration28Callback(),
+ )
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index d5cf319594b..8da1e97daf7 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -4,6 +4,7 @@
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.workflow_records.workflow_records_common import (
+ WORKFLOW_LIBRARY_DEFAULT_USER_ID,
Workflow,
WorkflowCategory,
WorkflowRecordDTO,
@@ -22,7 +23,7 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
pass
@abstractmethod
- def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
+ def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO:
"""Creates a workflow."""
pass
@@ -47,6 +48,8 @@ def get_many(
query: Optional[str],
tags: Optional[list[str]],
has_been_opened: Optional[bool],
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
@@ -56,6 +59,8 @@ def counts_by_category(
self,
categories: list[WorkflowCategory],
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided categories."""
pass
@@ -66,6 +71,8 @@ def counts_by_tag(
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided tags."""
pass
@@ -79,6 +86,13 @@ def update_opened_at(self, workflow_id: str) -> None:
def get_all_tags(
self,
categories: Optional[list[WorkflowCategory]] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> list[str]:
"""Gets all unique tags from workflows."""
pass
+
+ @abstractmethod
+ def update_is_public(self, workflow_id: str, is_public: bool) -> WorkflowRecordDTO:
+ """Updates the is_public field of a workflow."""
+ pass
diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py
index e0cea37468d..9c505530c90 100644
--- a/invokeai/app/services/workflow_records/workflow_records_common.py
+++ b/invokeai/app/services/workflow_records/workflow_records_common.py
@@ -9,6 +9,9 @@
__workflow_meta_version__ = semver.Version.parse("1.0.0")
+WORKFLOW_LIBRARY_DEFAULT_USER_ID = "system"
+"""Default user_id for workflows created in single-user mode or migrated from pre-multiuser databases."""
+
class ExposedField(BaseModel):
nodeId: str
@@ -26,6 +29,7 @@ class WorkflowRecordOrderBy(str, Enum, metaclass=MetaEnum):
UpdatedAt = "updated_at"
OpenedAt = "opened_at"
Name = "name"
+ IsPublic = "is_public"
class WorkflowCategory(str, Enum, metaclass=MetaEnum):
@@ -100,6 +104,8 @@ class WorkflowRecordDTOBase(BaseModel):
opened_at: Optional[Union[datetime.datetime, str]] = Field(
default=None, description="The opened timestamp of the workflow."
)
+ user_id: str = Field(description="The id of the user who owns this workflow.")
+ is_public: bool = Field(description="Whether this workflow is shared with all users.")
class WorkflowRecordDTO(WorkflowRecordDTOBase):
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 0f72f7cd92c..0e6dfe1b700 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -7,6 +7,7 @@
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase
from invokeai.app.services.workflow_records.workflow_records_common import (
+ WORKFLOW_LIBRARY_DEFAULT_USER_ID,
Workflow,
WorkflowCategory,
WorkflowNotFoundError,
@@ -36,7 +37,7 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
with self._db.transaction() as cursor:
cursor.execute(
"""--sql
- SELECT workflow_id, workflow, name, created_at, updated_at, opened_at
+ SELECT workflow_id, workflow, name, created_at, updated_at, opened_at, user_id, is_public
FROM workflow_library
WHERE workflow_id = ?;
""",
@@ -47,7 +48,7 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found")
return WorkflowRecordDTO.from_dict(dict(row))
- def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
+ def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO:
if workflow.meta.category is WorkflowCategory.Default:
raise ValueError("Default workflows cannot be created via this method")
@@ -57,11 +58,12 @@ def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
"""--sql
INSERT OR IGNORE INTO workflow_library (
workflow_id,
- workflow
+ workflow,
+ user_id
)
- VALUES (?, ?);
+ VALUES (?, ?, ?);
""",
- (workflow_with_id.id, workflow_with_id.model_dump_json()),
+ (workflow_with_id.id, workflow_with_id.model_dump_json(), user_id),
)
return self.get(workflow_with_id.id)
@@ -94,6 +96,31 @@ def delete(self, workflow_id: str) -> None:
)
return None
+ def update_is_public(self, workflow_id: str, is_public: bool) -> WorkflowRecordDTO:
+ """Updates the is_public field of a workflow and manages the 'shared' tag automatically."""
+ record = self.get(workflow_id)
+ workflow = record.workflow
+
+ # Manage "shared" tag: add when public, remove when private
+ tags_list = [t.strip() for t in workflow.tags.split(",") if t.strip()] if workflow.tags else []
+ if is_public and "shared" not in tags_list:
+ tags_list.append("shared")
+ elif not is_public and "shared" in tags_list:
+ tags_list.remove("shared")
+ updated_tags = ", ".join(tags_list)
+ updated_workflow = workflow.model_copy(update={"tags": updated_tags})
+
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ UPDATE workflow_library
+ SET workflow = ?, is_public = ?
+ WHERE workflow_id = ? AND category = 'user';
+ """,
+ (updated_workflow.model_dump_json(), is_public, workflow_id),
+ )
+ return self.get(workflow_id)
+
def get_many(
self,
order_by: WorkflowRecordOrderBy,
@@ -104,6 +131,8 @@ def get_many(
query: Optional[str] = None,
tags: Optional[list[str]] = None,
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
with self._db.transaction() as cursor:
# sanitize!
@@ -122,7 +151,9 @@ def get_many(
created_at,
updated_at,
opened_at,
- tags
+ tags,
+ user_id,
+ is_public
FROM workflow_library
"""
count_query = "SELECT COUNT(*) FROM workflow_library"
@@ -177,6 +208,15 @@ def get_many(
conditions.append(query_condition)
params.extend([wildcard_query, wildcard_query, wildcard_query])
+ if user_id is not None:
+ conditions.append("user_id = ?")
+ params.append(user_id)
+
+ if is_public is True:
+ conditions.append("is_public = TRUE")
+ elif is_public is False:
+ conditions.append("is_public = FALSE")
+
if conditions:
# If there are conditions, add a WHERE clause and then join the conditions
main_query += " WHERE "
@@ -226,6 +266,8 @@ def counts_by_tag(
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> dict[str, int]:
if not tags:
return {}
@@ -248,6 +290,15 @@ def counts_by_tag(
elif has_been_opened is False:
base_conditions.append("opened_at IS NULL")
+ if user_id is not None:
+ base_conditions.append("user_id = ?")
+ base_params.append(user_id)
+
+ if is_public is True:
+ base_conditions.append("is_public = TRUE")
+ elif is_public is False:
+ base_conditions.append("is_public = FALSE")
+
# For each tag to count, run a separate query
for tag in tags:
# Start with the base conditions
@@ -277,6 +328,8 @@ def counts_by_category(
self,
categories: list[WorkflowCategory],
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> dict[str, int]:
with self._db.transaction() as cursor:
result: dict[str, int] = {}
@@ -296,6 +349,15 @@ def counts_by_category(
elif has_been_opened is False:
base_conditions.append("opened_at IS NULL")
+ if user_id is not None:
+ base_conditions.append("user_id = ?")
+ base_params.append(user_id)
+
+ if is_public is True:
+ base_conditions.append("is_public = TRUE")
+ elif is_public is False:
+ base_conditions.append("is_public = FALSE")
+
# For each category to count, run a separate query
for category in categories:
# Start with the base conditions
@@ -335,6 +397,8 @@ def update_opened_at(self, workflow_id: str) -> None:
def get_all_tags(
self,
categories: Optional[list[WorkflowCategory]] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> list[str]:
with self._db.transaction() as cursor:
conditions: list[str] = []
@@ -349,6 +413,15 @@ def get_all_tags(
conditions.append(f"category IN ({placeholders})")
params.extend([category.value for category in categories])
+ if user_id is not None:
+ conditions.append("user_id = ?")
+ params.append(user_id)
+
+ if is_public is True:
+ conditions.append("is_public = TRUE")
+ elif is_public is False:
+ conditions.append("is_public = FALSE")
+
stmt = """--sql
SELECT DISTINCT tags
FROM workflow_library
diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json
index af8476528d6..19e5a3a68e9 100644
--- a/invokeai/frontend/web/openapi.json
+++ b/invokeai/frontend/web/openapi.json
@@ -6463,6 +6463,23 @@
"title": "Has Been Opened"
},
"description": "Whether to include/exclude recent workflows"
+ },
+ {
+ "name": "is_public",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Public"
+ },
+ "description": "Filter by public/shared status"
}
],
"responses": {
@@ -6655,6 +6672,23 @@
"title": "Categories"
},
"description": "The categories to include"
+ },
+ {
+ "name": "is_public",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Public"
+ },
+ "description": "Filter by public/shared status"
}
],
"responses": {
@@ -6744,6 +6778,23 @@
"title": "Has Been Opened"
},
"description": "Whether to include/exclude recent workflows"
+ },
+ {
+ "name": "is_public",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Public"
+ },
+ "description": "Filter by public/shared status"
}
],
"responses": {
@@ -6812,6 +6863,23 @@
"title": "Has Been Opened"
},
"description": "Whether to include/exclude recent workflows"
+ },
+ {
+ "name": "is_public",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Public"
+ },
+ "description": "Filter by public/shared status"
}
],
"responses": {
@@ -7352,6 +7420,67 @@
}
}
}
+ },
+ "/api/v1/workflows/i/{workflow_id}/is_public": {
+ "patch": {
+ "tags": ["workflows"],
+ "summary": "Update Workflow Is Public",
+ "description": "Updates whether a workflow is shared publicly",
+ "operationId": "update_workflow_is_public",
+ "parameters": [
+ {
+ "name": "workflow_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Workflow Id"
+ },
+ "description": "The workflow to update"
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "is_public": {
+ "type": "boolean",
+ "title": "Is Public",
+ "description": "Whether the workflow should be shared publicly"
+ }
+ },
+ "type": "object",
+ "required": ["is_public"],
+ "title": "Body_update_workflow_is_public"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowRecordDTO"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"components": {
@@ -59137,10 +59266,20 @@
"workflow": {
"$ref": "#/components/schemas/Workflow",
"description": "The workflow."
+ },
+ "user_id": {
+ "type": "string",
+ "title": "User Id",
+ "description": "The id of the user who owns this workflow."
+ },
+ "is_public": {
+ "type": "boolean",
+ "title": "Is Public",
+ "description": "Whether this workflow is shared with all users."
}
},
"type": "object",
- "required": ["workflow_id", "name", "created_at", "updated_at", "workflow"],
+ "required": ["workflow_id", "name", "created_at", "updated_at", "workflow", "user_id", "is_public"],
"title": "WorkflowRecordDTO"
},
"WorkflowRecordListItemWithThumbnailDTO": {
@@ -59222,15 +59361,35 @@
],
"title": "Thumbnail Url",
"description": "The URL of the workflow thumbnail."
+ },
+ "user_id": {
+ "type": "string",
+ "title": "User Id",
+ "description": "The id of the user who owns this workflow."
+ },
+ "is_public": {
+ "type": "boolean",
+ "title": "Is Public",
+ "description": "Whether this workflow is shared with all users."
}
},
"type": "object",
- "required": ["workflow_id", "name", "created_at", "updated_at", "description", "category", "tags"],
+ "required": [
+ "workflow_id",
+ "name",
+ "created_at",
+ "updated_at",
+ "description",
+ "category",
+ "tags",
+ "user_id",
+ "is_public"
+ ],
"title": "WorkflowRecordListItemWithThumbnailDTO"
},
"WorkflowRecordOrderBy": {
"type": "string",
- "enum": ["created_at", "updated_at", "opened_at", "name"],
+ "enum": ["created_at", "updated_at", "opened_at", "name", "is_public"],
"title": "WorkflowRecordOrderBy",
"description": "The order by options for workflow records"
},
@@ -59303,10 +59462,20 @@
],
"title": "Thumbnail Url",
"description": "The URL of the workflow thumbnail."
+ },
+ "user_id": {
+ "type": "string",
+ "title": "User Id",
+ "description": "The id of the user who owns this workflow."
+ },
+ "is_public": {
+ "type": "boolean",
+ "title": "Is Public",
+ "description": "Whether this workflow is shared with all users."
}
},
"type": "object",
- "required": ["workflow_id", "name", "created_at", "updated_at", "workflow"],
+ "required": ["workflow_id", "name", "created_at", "updated_at", "workflow", "user_id", "is_public"],
"title": "WorkflowRecordWithThumbnailDTO"
},
"WorkflowWithoutID": {
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 2db971d06a6..3b7f1c2b5d9 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -2158,6 +2158,8 @@
"tags": "Tags",
"yourWorkflows": "Your Workflows",
"recentlyOpened": "Recently Opened",
+ "sharedWorkflows": "Shared Workflows",
+ "shareWorkflow": "Shared workflow",
"noRecentWorkflows": "No Recent Workflows",
"private": "Private",
"shared": "Shared",
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx
index c1094abf86d..11d27335352 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx
@@ -1,8 +1,19 @@
import type { FormControlProps } from '@invoke-ai/ui-library';
-import { Box, Flex, FormControl, FormControlGroup, FormLabel, Image, Input, Textarea } from '@invoke-ai/ui-library';
+import {
+ Box,
+ Checkbox,
+ Flex,
+ FormControl,
+ FormControlGroup,
+ FormLabel,
+ Image,
+ Input,
+ Textarea,
+} from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import {
workflowAuthorChanged,
workflowContactChanged,
@@ -25,7 +36,8 @@ import {
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
-import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
+import { useGetWorkflowQuery, useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
import { WorkflowThumbnailEditor } from './WorkflowThumbnail/WorkflowThumbnailEditor';
@@ -95,6 +107,7 @@ const WorkflowGeneralTab = () => {
{t('nodes.workflowName')}
+
{t('nodes.workflowVersion')}
@@ -187,3 +200,40 @@ const Thumbnail = ({ id }: { id?: string | null }) => {
// This is a default workflow and it does not have a thumbnail set. Users may not edit the thumbnail.
return null;
};
+
+const ShareWorkflowCheckbox = ({ id }: { id?: string | null }) => {
+ const { t } = useTranslation();
+ const currentUser = useAppSelector(selectCurrentUser);
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const { data } = useGetWorkflowQuery(id ?? skipToken);
+ const [updateIsPublic, { isLoading }] = useUpdateWorkflowIsPublicMutation();
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ if (!id) {
+ return;
+ }
+ updateIsPublic({ workflow_id: id, is_public: e.target.checked });
+ },
+ [id, updateIsPublic]
+ );
+
+ // Only show for saved user workflows in multiuser mode when the current user is the owner or admin
+ if (!data || !id || data.workflow.meta.category !== 'user') {
+ return null;
+ }
+ if (setupStatus?.multiuser_enabled) {
+ const isOwner = currentUser !== null && data.user_id === currentUser.user_id;
+ const isAdmin = currentUser?.is_admin ?? false;
+ if (!isOwner && !isAdmin) {
+ return null;
+ }
+ }
+
+ return (
+
+
+ {t('workflows.shareWorkflow')}
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 73b046c83a9..501b8365db5 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -41,6 +41,7 @@ export const WorkflowLibrarySideNav = () => {
{t('workflows.recentlyOpened')}
+ {t('workflows.sharedWorkflows')}
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
index 79dff535b05..e6605d2076a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
@@ -32,6 +32,8 @@ const getCategories = (view: WorkflowLibraryView): WorkflowCategory[] => {
return ['user', 'default'];
case 'yours':
return ['user'];
+ case 'shared':
+ return ['user'];
default:
assert>(false);
}
@@ -44,6 +46,13 @@ const getHasBeenOpened = (view: WorkflowLibraryView): boolean | undefined => {
return undefined;
};
+const getIsPublic = (view: WorkflowLibraryView): boolean | undefined => {
+ if (view === 'shared') {
+ return true;
+ }
+ return undefined;
+};
+
const useInfiniteQueryAry = () => {
const orderBy = useAppSelector(selectWorkflowLibraryOrderBy);
const direction = useAppSelector(selectWorkflowLibraryDirection);
@@ -62,6 +71,7 @@ const useInfiniteQueryAry = () => {
query: debouncedSearchTerm,
tags: view === 'defaults' || view === 'yours' ? selectedTags : [],
has_been_opened: getHasBeenOpened(view),
+ is_public: getIsPublic(view),
} satisfies Parameters[0];
}, [orderBy, direction, view, debouncedSearchTerm, selectedTags]);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
index a1767765c93..a184f04039a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
@@ -1,13 +1,15 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
+import { Badge, Flex, Icon, Image, Spacer, Switch, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { selectWorkflowId } from 'features/nodes/store/selectors';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
-import { memo, useCallback, useMemo } from 'react';
+import { type ChangeEvent, memo, type MouseEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImage } from 'react-icons/pi';
+import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
import { DeleteWorkflow } from './WorkflowLibraryListItemActions/DeleteWorkflow';
@@ -33,12 +35,21 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
const { t } = useTranslation();
const dispatch = useAppDispatch();
const workflowId = useAppSelector(selectWorkflowId);
+ const currentUser = useAppSelector(selectCurrentUser);
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const isActive = useMemo(() => {
return workflowId === workflow.workflow_id;
}, [workflowId, workflow.workflow_id]);
+ const isOwner = useMemo(() => {
+ return currentUser !== null && workflow.user_id === currentUser.user_id;
+ }, [currentUser, workflow.user_id]);
+
+ const canEditOrDelete = useMemo(() => {
+ return isOwner || (currentUser?.is_admin ?? false);
+ }, [isOwner, currentUser]);
+
const tags = useMemo(() => {
if (!workflow.tags) {
return [];
@@ -102,6 +113,18 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
{t('workflows.opened')}
)}
+ {workflow.is_public && workflow.category !== 'default' && (
+
+ {t('workflows.shared')}
+
+ )}
{workflow.category === 'default' && (
)}
+ {isOwner && }
{workflow.category === 'default' && }
{workflow.category !== 'default' && (
<>
-
+ {canEditOrDelete && }
-
+ {canEditOrDelete && }
>
)}
@@ -152,6 +176,35 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
});
WorkflowListItem.displayName = 'WorkflowListItem';
+const ShareWorkflowToggle = memo(({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
+ const { t } = useTranslation();
+ const [updateIsPublic, { isLoading }] = useUpdateWorkflowIsPublicMutation();
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ e.stopPropagation();
+ updateIsPublic({ workflow_id: workflow.workflow_id, is_public: e.target.checked });
+ },
+ [updateIsPublic, workflow.workflow_id]
+ );
+
+ const handleClick = useCallback((e: MouseEvent) => {
+ e.stopPropagation();
+ }, []);
+
+ return (
+
+
+
+ {t('workflows.shared')}
+
+
+
+
+ );
+});
+ShareWorkflowToggle.displayName = 'ShareWorkflowToggle';
+
const UserThumbnailFallback = memo(() => {
return (
;
const isOrderBy = (v: unknown): v is OrderBy => zOrderBy.safeParse(v).success;
@@ -32,6 +32,7 @@ export const WorkflowSortControl = () => {
created_at: t('workflows.created'),
updated_at: t('workflows.updated'),
name: t('workflows.name'),
+ is_public: t('workflows.shared'),
}),
[t]
);
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
index ee85a03c18f..1d5d8554aeb 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -11,7 +11,7 @@ import {
} from 'services/api/types';
import z from 'zod';
-const zWorkflowLibraryView = z.enum(['recent', 'yours', 'defaults']);
+const zWorkflowLibraryView = z.enum(['recent', 'yours', 'shared', 'defaults']);
export type WorkflowLibraryView = z.infer;
const zWorkflowLibraryState = z.object({
@@ -55,6 +55,9 @@ const slice = createSlice({
if (action.payload === 'recent') {
state.orderBy = 'opened_at';
state.direction = 'DESC';
+ } else if (action.payload === 'shared') {
+ state.orderBy = 'name';
+ state.direction = 'ASC';
}
},
workflowLibraryTagToggled: (state, action: PayloadAction) => {
@@ -121,5 +124,11 @@ export const WORKFLOW_LIBRARY_TAG_CATEGORIES: WorkflowTagCategory[] = [
];
export const WORKFLOW_LIBRARY_TAGS = WORKFLOW_LIBRARY_TAG_CATEGORIES.flatMap(({ tags }) => tags);
-type WorkflowSortOption = 'opened_at' | 'created_at' | 'updated_at' | 'name';
-export const WORKFLOW_LIBRARY_SORT_OPTIONS: WorkflowSortOption[] = ['opened_at', 'created_at', 'updated_at', 'name'];
+type WorkflowSortOption = 'opened_at' | 'created_at' | 'updated_at' | 'name' | 'is_public';
+export const WORKFLOW_LIBRARY_SORT_OPTIONS: WorkflowSortOption[] = [
+ 'opened_at',
+ 'created_at',
+ 'updated_at',
+ 'name',
+ 'is_public',
+];
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
index 72ca9c309b3..e29ca82fa2b 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
@@ -5,6 +5,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
Button,
+ Checkbox,
Flex,
FormControl,
FormLabel,
@@ -19,6 +20,7 @@ import { t } from 'i18next';
import { atom, computed } from 'nanostores';
import type { ChangeEvent, RefObject } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
+import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
import { assert } from 'tsafe';
/**
@@ -87,8 +89,10 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
}
return '';
});
+ const [isPublic, setIsPublic] = useState(false);
const { createNewWorkflow } = useCreateLibraryWorkflow();
+ const [updateIsPublic] = useUpdateWorkflowIsPublicMutation();
const inputRef = useRef(null);
@@ -96,6 +100,10 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
setName(e.target.value);
}, []);
+ const onChangeIsPublic = useCallback((e: ChangeEvent) => {
+ setIsPublic(e.target.checked);
+ }, []);
+
const onClose = useCallback(() => {
$workflowToSave.set(null);
}, []);
@@ -110,10 +118,19 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
await createNewWorkflow({
workflow,
- onSuccess: onClose,
+ onSuccess: async (workflowId?: string) => {
+ if (isPublic && workflowId) {
+ try {
+ await updateIsPublic({ workflow_id: workflowId, is_public: true }).unwrap();
+ } catch {
+ // Sharing failed silently - workflow was saved, just not shared
+ }
+ }
+ onClose();
+ },
onError: onClose,
});
- }, [workflow, name, createNewWorkflow, onClose]);
+ }, [workflow, name, isPublic, createNewWorkflow, updateIsPublic, onClose]);
return (
@@ -126,6 +143,10 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
{t('workflows.workflowName')}
+
+
+ {t('workflows.shareWorkflow')}
+
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
index 543283c779c..37fe48726e0 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
@@ -29,7 +29,7 @@ export const isDraftWorkflow = (workflow: WorkflowV3): workflow is DraftWorkflow
type CreateLibraryWorkflowArg = {
workflow: DraftWorkflow;
- onSuccess?: () => void;
+ onSuccess?: (workflowId?: string) => void;
onError?: () => void;
};
@@ -70,7 +70,7 @@ export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => {
// When a workflow is saved, the form field initial values are updated to the current form field values
dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() }));
updateOpenedAt({ workflow_id: id });
- onSuccess?.();
+ onSuccess?.(id);
toast.update(toastRef.current, {
title: t('workflows.workflowSaved'),
status: 'success',
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index f58d3281a26..176546c90fd 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -157,6 +157,21 @@ export const workflowsApi = api.injectEndpoints({
}),
invalidatesTags: (result, error, workflow_id) => [{ type: 'Workflow', id: workflow_id }],
}),
+ updateWorkflowIsPublic: build.mutation<
+ paths['/api/v1/workflows/i/{workflow_id}/is_public']['patch']['responses']['200']['content']['application/json'],
+ { workflow_id: string; is_public: boolean }
+ >({
+ query: ({ workflow_id, is_public }) => ({
+ url: buildWorkflowsUrl(`i/${workflow_id}/is_public`),
+ method: 'PATCH',
+ body: { is_public },
+ }),
+ invalidatesTags: (result, error, { workflow_id }) => [
+ { type: 'Workflow', id: workflow_id },
+ { type: 'Workflow', id: LIST_TAG },
+ 'WorkflowCategoryCounts',
+ ],
+ }),
}),
});
@@ -173,4 +188,5 @@ export const {
useListWorkflowsInfiniteInfiniteQuery,
useSetWorkflowThumbnailMutation,
useDeleteWorkflowThumbnailMutation,
+ useUpdateWorkflowIsPublicMutation,
} = workflowsApi;
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index b605413787b..898423246a9 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -2001,6 +2001,26 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/api/v1/workflows/i/{workflow_id}/is_public": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * Update Workflow Is Public
+ * @description Updates whether a workflow is shared publicly
+ */
+ patch: operations["update_workflow_is_public"];
+ trace?: never;
+ };
"/api/v1/workflows/tags": {
parameters: {
query?: never;
@@ -3166,6 +3186,14 @@ export type components = {
/** @description The updated workflow */
workflow: components["schemas"]["Workflow"];
};
+ /** Body_update_workflow_is_public */
+ Body_update_workflow_is_public: {
+ /**
+ * Is Public
+ * @description Whether the workflow should be shared publicly
+ */
+ is_public: boolean;
+ };
/** Body_upload_image */
Body_upload_image: {
/**
@@ -27450,6 +27478,16 @@ export type components = {
* @description The opened timestamp of the workflow.
*/
opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
/** @description The workflow. */
workflow: components["schemas"]["Workflow"];
};
@@ -27480,6 +27518,16 @@ export type components = {
* @description The opened timestamp of the workflow.
*/
opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
/**
* Description
* @description The description of the workflow.
@@ -27503,7 +27551,7 @@ export type components = {
* @description The order by options for workflow records
* @enum {string}
*/
- WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name";
+ WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name" | "is_public";
/** WorkflowRecordWithThumbnailDTO */
WorkflowRecordWithThumbnailDTO: {
/**
@@ -27531,6 +27579,16 @@ export type components = {
* @description The opened timestamp of the workflow.
*/
opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
/** @description The workflow. */
workflow: components["schemas"]["Workflow"];
/**
@@ -32380,6 +32438,8 @@ export interface operations {
query?: string | null;
/** @description Whether to include/exclude recent workflows */
has_been_opened?: boolean | null;
+ /** @description Filter by public/shared status */
+ is_public?: boolean | null;
};
header?: never;
path?: never;
@@ -32554,11 +32614,49 @@ export interface operations {
};
};
};
+ update_workflow_is_public: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description The workflow to update */
+ workflow_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_update_workflow_is_public"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["WorkflowRecordDTO"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_all_tags: {
parameters: {
query?: {
/** @description The categories to include */
categories?: components["schemas"]["WorkflowCategory"][] | null;
+ /** @description Filter by public/shared status */
+ is_public?: boolean | null;
};
header?: never;
path?: never;
@@ -32595,6 +32693,8 @@ export interface operations {
categories?: components["schemas"]["WorkflowCategory"][] | null;
/** @description Whether to include/exclude recent workflows */
has_been_opened?: boolean | null;
+ /** @description Filter by public/shared status */
+ is_public?: boolean | null;
};
header?: never;
path?: never;
@@ -32631,6 +32731,8 @@ export interface operations {
categories: components["schemas"]["WorkflowCategory"][];
/** @description Whether to include/exclude recent workflows */
has_been_opened?: boolean | null;
+ /** @description Filter by public/shared status */
+ is_public?: boolean | null;
};
header?: never;
path?: never;
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index 5d56c346f87..80264f792c4 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -337,7 +337,7 @@ export type ModelInstallStatus = S['InstallStatus'];
export type Graph = S['Graph'];
export type NonNullableGraph = SetRequired;
export type Batch = S['Batch'];
-export const zWorkflowRecordOrderBy = z.enum(['name', 'created_at', 'updated_at', 'opened_at']);
+export const zWorkflowRecordOrderBy = z.enum(['name', 'created_at', 'updated_at', 'opened_at', 'is_public']);
export type WorkflowRecordOrderBy = z.infer;
assert>();
diff --git a/tests/app/routers/test_workflows_multiuser.py b/tests/app/routers/test_workflows_multiuser.py
new file mode 100644
index 00000000000..28b301e18e3
--- /dev/null
+++ b/tests/app/routers/test_workflows_multiuser.py
@@ -0,0 +1,334 @@
+"""Tests for multiuser workflow library functionality."""
+
+import logging
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api_app import app
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.invocation_services import InvocationServices
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.users.users_common import UserCreateRequest
+from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
+from invokeai.backend.util.logging import InvokeAILogger
+from tests.fixtures.sqlite_database import create_mock_sqlite_database
+
+
+class MockApiDependencies(ApiDependencies):
+ invoker: Invoker
+
+ def __init__(self, invoker: Invoker) -> None:
+ self.invoker = invoker
+
+
+WORKFLOW_BODY = {
+ "name": "Test Workflow",
+ "author": "",
+ "description": "A test workflow",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "",
+ "notes": "",
+ "nodes": [],
+ "edges": [],
+ "exposedFields": [],
+ "meta": {"version": "3.0.0", "category": "user"},
+ "id": None,
+ "form_fields": [],
+}
+
+
+@pytest.fixture
+def setup_jwt_secret():
+ from invokeai.app.services.auth.token_service import set_jwt_secret
+
+ set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+@pytest.fixture
+def mock_services() -> InvocationServices:
+ from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
+ from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
+ from invokeai.app.services.boards.boards_default import BoardService
+ from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
+ from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import (
+ ClientStatePersistenceSqlite,
+ )
+ from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
+ from invokeai.app.services.images.images_default import ImageService
+ from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
+ from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
+ from invokeai.app.services.users.users_default import UserService
+ from tests.test_nodes import TestEventService
+
+ configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
+ logger = InvokeAILogger.get_logger()
+ db = create_mock_sqlite_database(configuration, logger)
+
+ return InvocationServices(
+ board_image_records=SqliteBoardImageRecordStorage(db=db),
+ board_images=None, # type: ignore
+ board_records=SqliteBoardRecordStorage(db=db),
+ boards=BoardService(),
+ bulk_download=BulkDownloadService(),
+ configuration=configuration,
+ events=TestEventService(),
+ image_files=None, # type: ignore
+ image_records=SqliteImageRecordStorage(db=db),
+ images=ImageService(),
+ invocation_cache=MemoryInvocationCache(max_cache_size=0),
+ logger=logging, # type: ignore
+ model_images=None, # type: ignore
+ model_manager=None, # type: ignore
+ download_queue=None, # type: ignore
+ names=None, # type: ignore
+ performance_statistics=InvocationStatsService(),
+ session_processor=None, # type: ignore
+ session_queue=None, # type: ignore
+ urls=None, # type: ignore
+ workflow_records=SqliteWorkflowRecordsStorage(db=db),
+ tensors=None, # type: ignore
+ conditioning=None, # type: ignore
+ style_preset_records=None, # type: ignore
+ style_preset_image_files=None, # type: ignore
+ workflow_thumbnails=None, # type: ignore
+ model_relationship_records=None, # type: ignore
+ model_relationships=None, # type: ignore
+ client_state_persistence=ClientStatePersistenceSqlite(db=db),
+ users=UserService(db),
+ )
+
+
+def create_test_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
+ user_service = mock_invoker.services.users
+ user_data = UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
+ user = user_service.create(user_data)
+ return user.user_id
+
+
+def get_user_token(client: TestClient, email: str) -> str:
+ response = client.post(
+ "/api/v1/auth/login",
+ json={"email": email, "password": "TestPass123", "remember_me": False},
+ )
+ assert response.status_code == 200
+ return response.json()["token"]
+
+
+@pytest.fixture
+def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
+ mock_invoker.services.configuration.multiuser = True
+ mock_workflow_thumbnails = MagicMock()
+ mock_workflow_thumbnails.get_url.return_value = None
+ mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
+
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
+ yield
+
+
+@pytest.fixture
+def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient):
+ create_test_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
+ return get_user_token(client, "admin@test.com")
+
+
+@pytest.fixture
+def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
+ create_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
+ return get_user_token(client, "user1@test.com")
+
+
+@pytest.fixture
+def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
+ create_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
+ return get_user_token(client, "user2@test.com")
+
+
+def create_workflow(client: TestClient, token: str) -> str:
+ response = client.post(
+ "/api/v1/workflows/",
+ json={"workflow": WORKFLOW_BODY},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+ return response.json()["workflow_id"]
+
+
+# ---------------------------------------------------------------------------
+# Auth tests
+# ---------------------------------------------------------------------------
+
+
+def test_list_workflows_requires_auth(enable_multiuser: Any, client: TestClient):
+ response = client.get("/api/v1/workflows/")
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+def test_create_workflow_requires_auth(enable_multiuser: Any, client: TestClient):
+ response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+# ---------------------------------------------------------------------------
+# Ownership isolation
+# ---------------------------------------------------------------------------
+
+
+def test_workflows_are_isolated_between_users(client: TestClient, user1_token: str, user2_token: str):
+ """Users should only see their own workflows in list."""
+ # user1 creates a workflow
+ create_workflow(client, user1_token)
+
+ # user1 can see it
+ r1 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user1_token}"})
+ assert r1.status_code == 200
+ assert r1.json()["total"] == 1
+
+ # user2 cannot see user1's workflow
+ r2 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user2_token}"})
+ assert r2.status_code == 200
+ assert r2.json()["total"] == 0
+
+
+def test_user_cannot_delete_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.delete(
+ f"/api/v1/workflows/i/{workflow_id}",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_user_cannot_update_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ updated = {**WORKFLOW_BODY, "id": workflow_id, "name": "Hijacked"}
+ response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}",
+ json={"workflow": updated},
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_owner_can_delete_own_workflow(client: TestClient, user1_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.delete(
+ f"/api/v1/workflows/i/{workflow_id}",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == 200
+
+
+def test_admin_can_delete_any_workflow(client: TestClient, admin_token: str, user1_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.delete(
+ f"/api/v1/workflows/i/{workflow_id}",
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ assert response.status_code == 200
+
+
+# ---------------------------------------------------------------------------
+# Shared workflow (is_public)
+# ---------------------------------------------------------------------------
+
+
+def test_update_is_public_owner_succeeds(client: TestClient, user1_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == 200
+ assert response.json()["is_public"] is True
+
+
+def test_update_is_public_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_public_workflow_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
+ """A shared (is_public=True) workflow should appear when filtering with is_public=true."""
+ workflow_id = create_workflow(client, user1_token)
+ # Make it public
+ client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # user2 can see it through is_public=true filter
+ response = client.get(
+ "/api/v1/workflows/?categories=user&is_public=true",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == 200
+ ids = [w["workflow_id"] for w in response.json()["items"]]
+ assert workflow_id in ids
+
+
+def test_private_workflow_not_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
+ """A private (is_public=False) user workflow should NOT appear for another user."""
+ workflow_id = create_workflow(client, user1_token)
+
+ # user2 lists 'yours' style (their own workflows)
+ response = client.get(
+ "/api/v1/workflows/?categories=user",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == 200
+ ids = [w["workflow_id"] for w in response.json()["items"]]
+ assert workflow_id not in ids
+
+
+def test_public_workflow_still_in_owners_list(client: TestClient, user1_token: str):
+ """A shared workflow should still appear in the owner's own workflow list."""
+ workflow_id = create_workflow(client, user1_token)
+ client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # owner's 'yours' list (no is_public filter)
+ response = client.get(
+ "/api/v1/workflows/?categories=user",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == 200
+ ids = [w["workflow_id"] for w in response.json()["items"]]
+ assert workflow_id in ids
+
+
+def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_token: str):
+ """Created workflow should return user_id and is_public fields."""
+ response = client.post(
+ "/api/v1/workflows/",
+ json={"workflow": WORKFLOW_BODY},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "user_id" in data
+ assert "is_public" in data
+ assert data["is_public"] is False
From c9f2e2d7d3b26f10f73e224ec15700f570ddccb6 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Mar 2026 21:11:38 -0500
Subject: [PATCH 002/100] Restrict model sync to admin users only (#118)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
invokeai/app/api/routers/model_manager.py | 6 ++++--
.../src/features/modelManagerV2/subpanels/ModelManager.tsx | 2 +-
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py
index 234c6c96629..3f056d92e7c 100644
--- a/invokeai/app/api/routers/model_manager.py
+++ b/invokeai/app/api/routers/model_manager.py
@@ -1212,7 +1212,7 @@ class DeleteOrphanedModelsResponse(BaseModel):
operation_id="get_orphaned_models",
response_model=list[OrphanedModelInfo],
)
-async def get_orphaned_models() -> list[OrphanedModelInfo]:
+async def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]:
"""Find orphaned model directories.
Orphaned models are directories in the models folder that contain model files
@@ -1239,7 +1239,9 @@ async def get_orphaned_models() -> list[OrphanedModelInfo]:
operation_id="delete_orphaned_models",
response_model=DeleteOrphanedModelsResponse,
)
-async def delete_orphaned_models(request: DeleteOrphanedModelsRequest) -> DeleteOrphanedModelsResponse:
+async def delete_orphaned_models(
+ request: DeleteOrphanedModelsRequest, _: AdminUserOrDefault
+) -> DeleteOrphanedModelsResponse:
"""Delete specified orphaned model directories.
Args:
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
index f6e1a18f6fd..60200c8801f 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
@@ -37,7 +37,7 @@ export const ModelManager = memo(() => {
{t('common.modelManager')}
-
+ {canManageModels && }
{!!selectedModelKey && canManageModels && (
} onClick={handleClickAddModel}>
{t('modelManager.addModels')}
From 1ed9349820e9978df15ba694bc7e0af0c99e3869 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Mar 2026 21:22:05 -0500
Subject: [PATCH 003/100] feat: distinct splash screens for admin/non-admin
users in multiuser mode (#116)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
invokeai/app/api/routers/auth.py | 6 +-
invokeai/app/services/users/users_base.py | 9 +++
invokeai/app/services/users/users_default.py | 14 +++++
invokeai/frontend/web/public/locales/en.json | 8 ++-
.../ImageViewer/NoContentForViewer.tsx | 58 +++++++++++++++++--
.../hooks/useStarterModelsToast.tsx | 20 +++++--
.../parameters/components/ModelPicker.tsx | 29 ++++++++++
.../UpscaleWarning.tsx | 40 ++++++++++---
.../ui/layouts/WorkflowsLaunchpadPanel.tsx | 10 +++-
.../web/src/services/api/endpoints/auth.ts | 1 +
.../frontend/web/src/services/api/schema.ts | 5 ++
11 files changed, 177 insertions(+), 23 deletions(-)
diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py
index 11f2bacdc5c..7ab1ee611ae 100644
--- a/invokeai/app/api/routers/auth.py
+++ b/invokeai/app/api/routers/auth.py
@@ -72,6 +72,7 @@ class SetupStatusResponse(BaseModel):
setup_required: bool = Field(description="Whether initial setup is required")
multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled")
+ admin_email: str | None = Field(default=None, description="Email of the first active admin user, if any")
@auth_router.get("/status", response_model=SetupStatusResponse)
@@ -85,13 +86,14 @@ async def get_setup_status() -> SetupStatusResponse:
# If multiuser is disabled, setup is never required
if not config.multiuser:
- return SetupStatusResponse(setup_required=False, multiuser_enabled=False)
+ return SetupStatusResponse(setup_required=False, multiuser_enabled=False, admin_email=None)
# In multiuser mode, check if an admin exists
user_service = ApiDependencies.invoker.services.users
setup_required = not user_service.has_admin()
+ admin_email = user_service.get_admin_email()
- return SetupStatusResponse(setup_required=setup_required, multiuser_enabled=True)
+ return SetupStatusResponse(setup_required=setup_required, multiuser_enabled=True, admin_email=admin_email)
@auth_router.post("/login", response_model=LoginResponse)
diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py
index 6587a2aa3ae..b12797c5324 100644
--- a/invokeai/app/services/users/users_base.py
+++ b/invokeai/app/services/users/users_base.py
@@ -124,3 +124,12 @@ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
List of users
"""
pass
+
+ @abstractmethod
+ def get_admin_email(self) -> str | None:
+ """Get the email address of the first active admin user.
+
+ Returns:
+ Email address of the first active admin, or None if no admin exists
+ """
+ pass
diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py
index 36ccec9e7e2..1a4290623ec 100644
--- a/invokeai/app/services/users/users_default.py
+++ b/invokeai/app/services/users/users_default.py
@@ -249,3 +249,17 @@ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
)
for row in rows
]
+
+ def get_admin_email(self) -> str | None:
+ """Get the email address of the first active admin user."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT email FROM users
+ WHERE is_admin = TRUE AND is_active = TRUE
+ ORDER BY created_at ASC
+ LIMIT 1
+ """,
+ )
+ row = cursor.fetchone()
+ return row[0] if row else None
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 2db971d06a6..c4399063811 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1062,7 +1062,9 @@
"name": "Name",
"modelPickerFallbackNoModelsInstalled": "No models installed.",
"modelPickerFallbackNoModelsInstalled2": "Visit the Model Manager to install models.",
+ "modelPickerFallbackNoModelsInstalledNonAdmin": "No models installed. Ask your InvokeAI administrator () to install some models.",
"noModelsInstalledDesc1": "Install models with the",
+ "noModelsInstalledAskAdmin": "Ask your administrator to install some.",
"noModelSelected": "No Model Selected",
"noMatchingModels": "No matching models",
"noModelsInstalled": "No models installed",
@@ -2863,6 +2865,7 @@
"tileOverlap": "Tile Overlap",
"postProcessingMissingModelWarning": "Visit the Model Manager to install a post-processing (image to image) model.",
"missingModelsWarning": "Visit the Model Manager to install the required models:",
+ "missingModelsWarningNonAdmin": "Ask your InvokeAI administrator () to install the required models:",
"mainModelDesc": "Main model (SD1.5 or SDXL architecture)",
"tileControlNetModelDesc": "Tile ControlNet model for the chosen main model architecture",
"upscaleModelDesc": "Upscale (image to image) model",
@@ -2971,6 +2974,7 @@
},
"workflows": {
"description": "Workflows are reusable templates that automate image generation tasks, allowing you to quickly perform complex operations and get consistent results.",
+ "descriptionMultiuser": "Workflows are reusable templates that automate image generation tasks, allowing you to quickly perform complex operations and get consistent results. You may share your workflows with other users of the system by selecting 'Shared workflow' when you create or edit it.",
"learnMoreLink": "Learn more about creating workflows",
"browseTemplates": {
"title": "Browse Workflow Templates",
@@ -3049,9 +3053,11 @@
"toGetStartedLocal": "To get started, make sure to download or import models needed to run Invoke. Then, enter a prompt in the box and click Invoke to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the Gallery or edit them to the Canvas.",
"toGetStarted": "To get started, enter a prompt in the box and click Invoke to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the Gallery or edit them to the Canvas.",
"toGetStartedWorkflow": "To get started, fill in the fields on the left and press Invoke to generate your image. Want to explore more workflows? Click the folder icon next to the workflow title to see a list of other templates you can try.",
+ "toGetStartedNonAdmin": "To get started, ask your InvokeAI administrator () to install the AI models needed to run Invoke. Then, enter a prompt in the box and click Invoke to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the Gallery or edit them to the Canvas.",
"gettingStartedSeries": "Want more guidance? Check out our Getting Started Series for tips on unlocking the full potential of the Invoke Studio.",
"lowVRAMMode": "For best performance, follow our Low VRAM guide.",
- "noModelsInstalled": "It looks like you don't have any models installed! You can download a starter model bundle or import models."
+ "noModelsInstalled": "It looks like you don't have any models installed! You can download a starter model bundle or import models.",
+ "noModelsInstalledAskAdmin": "Ask your administrator to install some."
},
"whatsNew": {
"whatsNewInInvoke": "What's New in Invoke",
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx
index 1649a14c511..93e5ba111c4 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx
@@ -1,7 +1,9 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Alert, AlertDescription, AlertIcon, Button, Divider, Flex, Link, Spinner, Text } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { InvokeLogoIcon } from 'common/components/InvokeLogoIcon';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { LOADING_SYMBOL, useHasImages } from 'features/gallery/hooks/useHasImages';
import { setInstallModelsTabByName } from 'features/modelManagerV2/store/installModelsStore';
import { navigationApi } from 'features/ui/layouts/navigation-api';
@@ -9,16 +11,26 @@ import type { PropsWithChildren } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiArrowSquareOutBold, PiImageBold } from 'react-icons/pi';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { useMainModels } from 'services/api/hooks/modelsByType';
export const NoContentForViewer = memo(() => {
const hasImages = useHasImages();
const [mainModels, { data }] = useMainModels();
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const user = useAppSelector(selectCurrentUser);
const { t } = useTranslation();
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
+ const isAdmin = !isMultiuser || (user?.is_admin ?? false);
+ const adminEmail = setupStatus?.admin_email ?? null;
+
+ const modelsLoaded = data !== undefined;
+ const hasModels = mainModels.length > 0;
+
const showStarterBundles = useMemo(() => {
- return data && mainModels.length === 0;
- }, [mainModels.length, data]);
+ return modelsLoaded && !hasModels && isAdmin;
+ }, [modelsLoaded, hasModels, isAdmin]);
if (hasImages === LOADING_SYMBOL) {
// Blank bg w/ a spinner. The new user experience components below have an invoke logo, but it's not centered.
@@ -36,10 +48,18 @@ export const NoContentForViewer = memo(() => {
-
- {showStarterBundles && }
-
-
+ {isAdmin ? (
+ // Admin / single-user mode
+ <>
+ {modelsLoaded && hasModels ? : }
+ {showStarterBundles && }
+
+
+ >
+ ) : (
+ // Non-admin user in multiuser mode
+ <>{modelsLoaded && hasModels ? : }>
+ )}
);
@@ -89,6 +109,32 @@ const GetStartedLocal = () => {
);
};
+const GetStartedWithModels = () => {
+ return (
+
+
+
+ );
+};
+
+const GetStartedNonAdmin = ({ adminEmail }: { adminEmail: string | null }) => {
+ const AdminEmailLink = adminEmail ? (
+
+ {adminEmail}
+
+ ) : (
+
+ your administrator
+
+ );
+
+ return (
+
+
+
+ );
+};
+
const StarterBundlesCallout = () => {
const handleClickDownloadStarterModels = useCallback(() => {
navigationApi.switchToTab('models');
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
index d1774f9ded0..9b76fbbde67 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
@@ -1,10 +1,11 @@
import { Button, Text, useToast } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
-import { selectIsAuthenticated } from 'features/auth/store/authSlice';
+import { selectCurrentUser, selectIsAuthenticated } from 'features/auth/store/authSlice';
import { setInstallModelsTabByName } from 'features/modelManagerV2/store/installModelsStore';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { useMainModels } from 'services/api/hooks/modelsByType';
const TOAST_ID = 'starterModels';
@@ -15,6 +16,11 @@ export const useStarterModelsToast = () => {
const [mainModels, { data }] = useMainModels();
const toast = useToast();
const isAuthenticated = useAppSelector(selectIsAuthenticated);
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const user = useAppSelector(selectCurrentUser);
+
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
+ const isAdmin = !isMultiuser || (user?.is_admin ?? false);
useEffect(() => {
// Only show the toast if the user is authenticated
@@ -33,17 +39,17 @@ export const useStarterModelsToast = () => {
toast({
id: TOAST_ID,
title: t('modelManager.noModelsInstalled'),
- description: ,
+ description: isAdmin ? : ,
status: 'info',
isClosable: true,
duration: null,
onCloseComplete: () => setDidToast(true),
});
}
- }, [data, didToast, isAuthenticated, mainModels.length, t, toast]);
+ }, [data, didToast, isAuthenticated, isAdmin, mainModels.length, t, toast]);
};
-const ToastDescription = () => {
+const AdminToastDescription = () => {
const { t } = useTranslation();
const toast = useToast();
@@ -62,3 +68,9 @@ const ToastDescription = () => {
);
};
+
+const NonAdminToastDescription = () => {
+ const { t } = useTranslation();
+
+ return {t('modelManager.noModelsInstalledAskAdmin')};
+};
diff --git a/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx b/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx
index c5397791b84..f40f1d29c1c 100644
--- a/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx
@@ -3,6 +3,7 @@ import {
Button,
Flex,
Icon,
+ Link,
Popover,
PopoverArrow,
PopoverBody,
@@ -20,6 +21,7 @@ import { buildGroup, getRegex, isGroup, Picker, usePickerContext } from 'common/
import { useDisclosure } from 'common/hooks/useBoolean';
import { typedMemo } from 'common/util/typedMemo';
import { uniq } from 'es-toolkit/compat';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { selectLoRAsSlice } from 'features/controlLayers/store/lorasSlice';
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import { MODEL_BASE_TO_COLOR, MODEL_BASE_TO_LONG_NAME, MODEL_BASE_TO_SHORT_NAME } from 'features/modelManagerV2/models';
@@ -32,6 +34,7 @@ import { filesize } from 'filesize';
import { memo, useCallback, useMemo, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiCaretDownBold, PiLinkSimple } from 'react-icons/pi';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { useGetRelatedModelIdsBatchQuery } from 'services/api/endpoints/modelRelationships';
import type { AnyModelConfig } from 'services/api/types';
@@ -82,6 +85,32 @@ const components = {
const NoOptionsFallback = memo(({ noOptionsText }: { noOptionsText?: string }) => {
const { t } = useTranslation();
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const user = useAppSelector(selectCurrentUser);
+
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
+ const isAdmin = !isMultiuser || (user?.is_admin ?? false);
+ const adminEmail = setupStatus?.admin_email ?? null;
+
+ if (!isAdmin) {
+ const AdminEmailLink = adminEmail ? (
+
+ {adminEmail}
+
+ ) : (
+
+ your administrator
+
+ );
+
+ return (
+
+
+
+
+
+ );
+ }
return (
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleWarning.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleWarning.tsx
index 7d0a7ee2def..ff19e7ebb31 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleWarning.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleWarning.tsx
@@ -1,5 +1,6 @@
-import { Button, Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
+import { Button, Flex, Link, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { selectModel } from 'features/controlLayers/store/paramsSlice';
import { setInstallModelsTabByName } from 'features/modelManagerV2/store/installModelsStore';
import {
@@ -10,6 +11,7 @@ import {
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useCallback, useEffect, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { useControlNetModels } from 'services/api/hooks/modelsByType';
export const UpscaleWarning = () => {
@@ -19,6 +21,12 @@ export const UpscaleWarning = () => {
const tileControlnetModel = useAppSelector(selectTileControlNetModel);
const dispatch = useAppDispatch();
const [modelConfigs, { isLoading }] = useControlNetModels();
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const user = useAppSelector(selectCurrentUser);
+
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
+ const isAdmin = !isMultiuser || (user?.is_admin ?? false);
+ const adminEmail = setupStatus?.admin_email ?? null;
useEffect(() => {
const validModel = modelConfigs.find((cnetModel) => {
@@ -59,19 +67,33 @@ export const UpscaleWarning = () => {
return null;
}
+ const AdminEmailLink = adminEmail ? (
+
+ {adminEmail}
+
+ ) : (
+
+ your administrator
+
+ );
+
return (
{!isBaseModelCompatible && {t('upscaling.incompatibleBaseModelDesc')}}
{warnings.length > 0 && (
-
- ),
- }}
- />
+ {isAdmin ? (
+
+ ),
+ }}
+ />
+ ) : (
+
+ )}
)}
{warnings.length > 0 && (
diff --git a/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx
index d432f3193ef..b0d087528ad 100644
--- a/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx
+++ b/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx
@@ -6,6 +6,7 @@ import { memo, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { PiFilePlusBold, PiFolderOpenBold, PiUploadBold } from 'react-icons/pi';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { LaunchpadButton } from './LaunchpadButton';
import { LaunchpadContainer } from './LaunchpadContainer';
@@ -14,6 +15,9 @@ export const WorkflowsLaunchpadPanel = memo(() => {
const { t } = useTranslation();
const workflowLibraryModal = useWorkflowLibraryModal();
const newWorkflow = useNewWorkflow();
+ const { data: setupStatus } = useGetSetupStatusQuery();
+
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
const handleBrowseTemplates = useCallback(() => {
workflowLibraryModal.open();
@@ -45,11 +49,15 @@ export const WorkflowsLaunchpadPanel = memo(() => {
multiple: false,
});
+ const descriptionKey = isMultiuser
+ ? 'ui.launchpad.workflows.descriptionMultiuser'
+ : 'ui.launchpad.workflows.description';
+
return (
{/* Description */}
- {t('ui.launchpad.workflows.description')}
+ {t(descriptionKey)}
diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.ts
index ba81c08136e..a2d6a292731 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/auth.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/auth.ts
@@ -33,6 +33,7 @@ type LogoutResponse = {
type SetupStatusResponse = {
setup_required: boolean;
multiuser_enabled: boolean;
+ admin_email: string | null;
};
export const authApi = api.injectEndpoints({
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index b605413787b..00840bb6688 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -24284,6 +24284,11 @@ export type components = {
* @description Whether multiuser mode is enabled
*/
multiuser_enabled: boolean;
+ /**
+ * Admin Email
+ * @description Email of the first active admin user, if any
+ */
+ admin_email?: string | null;
};
/**
* Show Image
From b37bc8d43ba0d2be063ba220ee9db164668cafbc Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 6 Mar 2026 10:58:09 -0500
Subject: [PATCH 004/100] Disable Save when editing another user's shared
workflow in multiuser mode (#120)
* Disable Save when editing another user's shared workflow in multiuser mode
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
.../panels/TopPanel/SaveWorkflowButton.tsx | 4 +-
.../WorkflowListMenu/SaveWorkflowButton.tsx | 5 ++
.../SaveWorkflowMenuItem.tsx | 4 +-
.../hooks/useIsCurrentWorkflowOwner.ts | 48 +++++++++++++++++++
4 files changed, 59 insertions(+), 2 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/hooks/useIsCurrentWorkflowOwner.ts
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx
index 91c6c1dae38..fe4b889f540 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx
@@ -1,5 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
+import { useIsCurrentWorkflowOwner } from 'features/workflowLibrary/hooks/useIsCurrentWorkflowOwner';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,6 +9,7 @@ import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
+ const isCurrentWorkflowOwner = useIsCurrentWorkflowOwner();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
return (
@@ -15,7 +17,7 @@ const SaveWorkflowButton = () => {
tooltip={t('workflows.saveWorkflow')}
aria-label={t('workflows.saveWorkflow')}
icon={}
- isDisabled={!doesWorkflowHaveUnsavedChanges}
+ isDisabled={!doesWorkflowHaveUnsavedChanges || !isCurrentWorkflowOwner}
onClick={saveOrSaveAsWorkflow}
pointerEvents="auto"
/>
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx
index 39a93e4a382..779d6f018ee 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx
@@ -1,4 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library';
+import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
+import { useIsCurrentWorkflowOwner } from 'features/workflowLibrary/hooks/useIsCurrentWorkflowOwner';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -7,12 +9,15 @@ import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
+ const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
+ const isCurrentWorkflowOwner = useIsCurrentWorkflowOwner();
return (
}
+ isDisabled={!doesWorkflowHaveUnsavedChanges || !isCurrentWorkflowOwner}
onClick={saveOrSaveAsWorkflow}
pointerEvents="auto"
variant="ghost"
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx
index 6f5acc431ed..e683cfdbefd 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx
@@ -1,5 +1,6 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
+import { useIsCurrentWorkflowOwner } from 'features/workflowLibrary/hooks/useIsCurrentWorkflowOwner';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,11 +10,12 @@ const SaveWorkflowMenuItem = () => {
const { t } = useTranslation();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
+ const isCurrentWorkflowOwner = useIsCurrentWorkflowOwner();
return (
}
onClick={saveOrSaveAsWorkflow}
>
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useIsCurrentWorkflowOwner.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useIsCurrentWorkflowOwner.ts
new file mode 100644
index 00000000000..5183c9050b7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useIsCurrentWorkflowOwner.ts
@@ -0,0 +1,48 @@
+import { skipToken } from '@reduxjs/toolkit/query';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
+import { selectWorkflowId } from 'features/nodes/store/selectors';
+import { useMemo } from 'react';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
+import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
+
+/**
+ * Returns true if the current user can save the currently-loaded workflow directly (not as a copy).
+ *
+ * In single-user mode, this always returns true.
+ * In multiuser mode, returns true when:
+ * - The workflow has no ID (new, unsaved workflow — will open Save As)
+ * - The current user is the owner of the workflow
+ * - The current user is an admin
+ */
+export const useIsCurrentWorkflowOwner = (): boolean => {
+ const workflowId = useAppSelector(selectWorkflowId);
+ const currentUser = useAppSelector(selectCurrentUser);
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const { data: workflowData } = useGetWorkflowQuery(workflowId ?? skipToken);
+
+ return useMemo(() => {
+ // In single-user mode there is no concept of ownership, so saving is always allowed.
+ if (!setupStatus?.multiuser_enabled) {
+ return true;
+ }
+
+ // No authenticated user — be permissive.
+ if (!currentUser) {
+ return true;
+ }
+
+ // No workflow ID means this is a new/unsaved workflow. Clicking "Save" will open the
+ // Save As dialog, so we should not block it.
+ if (!workflowId) {
+ return true;
+ }
+
+ // API data not yet available — be permissive to avoid incorrect disabling during loading.
+ if (!workflowData) {
+ return true;
+ }
+
+ return workflowData.user_id === currentUser.user_id || currentUser.is_admin;
+ }, [setupStatus?.multiuser_enabled, workflowId, workflowData, currentUser]);
+};
From 3dbc2908939d7e74f039dff37612b71d16ccb5a6 Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Sun, 8 Mar 2026 22:24:18 -0400
Subject: [PATCH 005/100] chore(app): ruff
---
invokeai/app/services/users/users_base.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py
index 07d16d99457..22721f81b0d 100644
--- a/invokeai/app/services/users/users_base.py
+++ b/invokeai/app/services/users/users_base.py
@@ -134,6 +134,7 @@ def get_admin_email(self) -> str | None:
"""
pass
+ @abstractmethod
def count_admins(self) -> int:
"""Count active admin users.
From 13faa0f69c125a99721cffe06eb9b8b82e79336b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 02:51:42 +0000
Subject: [PATCH 006/100] Add board visibility (private/shared/public) feature
with tests and UI
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
invokeai/app/api/routers/boards.py | 17 +-
.../board_records/board_records_common.py | 22 ++
.../board_records/board_records_sqlite.py | 22 +-
.../app/services/shared/sqlite/sqlite_util.py | 2 +
.../migrations/migration_29.py | 59 +++++
invokeai/frontend/web/public/locales/en.json | 12 +-
.../components/Boards/BoardContextMenu.tsx | 77 +++++-
.../Boards/BoardsList/GalleryBoard.tsx | 16 +-
.../frontend/web/src/services/api/schema.ts | 10 +
tests/app/routers/test_boards_multiuser.py | 220 ++++++++++++++++++
10 files changed, 446 insertions(+), 11 deletions(-)
create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py
diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py
index e93bb8b2a9b..5330951a667 100644
--- a/invokeai/app/api/routers/boards.py
+++ b/invokeai/app/api/routers/boards.py
@@ -6,7 +6,7 @@
from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
-from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
+from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy, BoardVisibility
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
@@ -56,7 +56,14 @@ async def get_board(
except Exception:
raise HTTPException(status_code=404, detail="Board not found")
- if not current_user.is_admin and result.user_id != current_user.user_id:
+ # Admins can access any board.
+ # Owners can access their own boards.
+ # Shared and public boards are visible to all authenticated users.
+ if (
+ not current_user.is_admin
+ and result.user_id != current_user.user_id
+ and result.board_visibility == BoardVisibility.Private
+ ):
raise HTTPException(status_code=403, detail="Not authorized to access this board")
return result
@@ -188,7 +195,11 @@ async def list_all_board_image_names(
except Exception:
raise HTTPException(status_code=404, detail="Board not found")
- if not current_user.is_admin and board.user_id != current_user.user_id:
+ if (
+ not current_user.is_admin
+ and board.user_id != current_user.user_id
+ and board.board_visibility == BoardVisibility.Private
+ ):
raise HTTPException(status_code=403, detail="Not authorized to access this board")
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py
index ab6355a3930..b263f264cb8 100644
--- a/invokeai/app/services/board_records/board_records_common.py
+++ b/invokeai/app/services/board_records/board_records_common.py
@@ -9,6 +9,17 @@
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
+class BoardVisibility(str, Enum, metaclass=MetaEnum):
+ """The visibility options for a board."""
+
+ Private = "private"
+ """Only the board owner (and admins) can see and modify this board."""
+ Shared = "shared"
+ """All users can view this board, but only the owner (and admins) can modify it."""
+ Public = "public"
+ """All users can view this board; only the owner (and admins) can modify its structure."""
+
+
class BoardRecord(BaseModelExcludeNull):
"""Deserialized board record."""
@@ -28,6 +39,10 @@ class BoardRecord(BaseModelExcludeNull):
"""The name of the cover image of the board."""
archived: bool = Field(description="Whether or not the board is archived.")
"""Whether or not the board is archived."""
+ board_visibility: BoardVisibility = Field(
+ default=BoardVisibility.Private, description="The visibility of the board."
+ )
+ """The visibility of the board (private, shared, or public)."""
def deserialize_board_record(board_dict: dict) -> BoardRecord:
@@ -44,6 +59,11 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord:
updated_at = board_dict.get("updated_at", get_iso_timestamp())
deleted_at = board_dict.get("deleted_at", get_iso_timestamp())
archived = board_dict.get("archived", False)
+ board_visibility_raw = board_dict.get("board_visibility", BoardVisibility.Private.value)
+ try:
+ board_visibility = BoardVisibility(board_visibility_raw)
+ except ValueError:
+ board_visibility = BoardVisibility.Private
return BoardRecord(
board_id=board_id,
@@ -54,6 +74,7 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord:
updated_at=updated_at,
deleted_at=deleted_at,
archived=archived,
+ board_visibility=board_visibility,
)
@@ -61,6 +82,7 @@ class BoardChanges(BaseModel, extra="forbid"):
board_name: Optional[str] = Field(default=None, description="The board's new name.", max_length=300)
cover_image_name: Optional[str] = Field(default=None, description="The name of the board's new cover image.")
archived: Optional[bool] = Field(default=None, description="Whether or not the board is archived")
+ board_visibility: Optional[BoardVisibility] = Field(default=None, description="The visibility of the board.")
class BoardRecordOrderBy(str, Enum, metaclass=MetaEnum):
diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py
index a54f65686fd..f5e36954725 100644
--- a/invokeai/app/services/board_records/board_records_sqlite.py
+++ b/invokeai/app/services/board_records/board_records_sqlite.py
@@ -9,6 +9,7 @@
BoardRecordNotFoundException,
BoardRecordOrderBy,
BoardRecordSaveException,
+ BoardVisibility,
deserialize_board_record,
)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
@@ -116,6 +117,17 @@ def update(
(changes.archived, board_id),
)
+ # Change the visibility of a board
+ if changes.board_visibility is not None:
+ cursor.execute(
+ """--sql
+ UPDATE boards
+ SET board_visibility = ?
+ WHERE board_id = ?;
+ """,
+ (changes.board_visibility.value, board_id),
+ )
+
except sqlite3.Error as e:
raise BoardRecordSaveException from e
return self.get(board_id)
@@ -155,7 +167,7 @@ def get_many(
SELECT DISTINCT boards.*
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
- WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1)
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
{archived_filter}
ORDER BY {order_by} {direction}
LIMIT ? OFFSET ?;
@@ -194,14 +206,14 @@ def get_many(
SELECT COUNT(DISTINCT boards.board_id)
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
- WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1);
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'));
"""
else:
count_query = """
SELECT COUNT(DISTINCT boards.board_id)
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
- WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1)
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
AND boards.archived = 0;
"""
@@ -251,7 +263,7 @@ def get_all(
SELECT DISTINCT boards.*
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
- WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1)
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
{archived_filter}
ORDER BY LOWER(boards.board_name) {direction}
"""
@@ -260,7 +272,7 @@ def get_all(
SELECT DISTINCT boards.*
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
- WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1)
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
{archived_filter}
ORDER BY {order_by} {direction}
"""
diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py
index 2478e8cdcae..fb8ca9fca38 100644
--- a/invokeai/app/services/shared/sqlite/sqlite_util.py
+++ b/invokeai/app/services/shared/sqlite/sqlite_util.py
@@ -31,6 +31,7 @@
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_26 import build_migration_26
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
@@ -79,6 +80,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_26(app_config=config, logger=logger))
migrator.register_migration(build_migration_27())
migrator.register_migration(build_migration_28())
+ migrator.register_migration(build_migration_29())
migrator.run_migrations()
return db
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py
new file mode 100644
index 00000000000..c3fa48e6377
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py
@@ -0,0 +1,59 @@
+"""Migration 29: Add board_visibility column to boards table.
+
+This migration adds a board_visibility column to the boards table to support
+three visibility levels:
+ - 'private': only the board owner (and admins) can view/modify
+ - 'shared': all users can view, but only the owner (and admins) can modify
+ - 'public': all users can view; only the owner (and admins) can modify the
+ board structure (rename/archive/delete)
+
+Existing boards with is_public = 1 are migrated to 'public'.
+All other existing boards default to 'private'.
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration29Callback:
+ """Migration to add board_visibility column to the boards table."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._update_boards_table(cursor)
+
+ def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add board_visibility column to boards table."""
+ # Check if boards table exists
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(boards);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "board_visibility" not in columns:
+ cursor.execute(
+ "ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';"
+ )
+ cursor.execute(
+ "CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);"
+ )
+ # Migrate existing is_public = 1 boards to 'public'
+ if "is_public" in columns:
+ cursor.execute(
+ "UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;"
+ )
+
+
+def build_migration_29() -> Migration:
+ """Builds the migration object for migrating from version 28 to version 29.
+
+ This migration adds the board_visibility column to the boards table,
+ supporting 'private', 'shared', and 'public' visibility levels.
+ """
+ return Migration(
+ from_version=28,
+ to_version=29,
+ callback=Migration29Callback(),
+ )
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 41dda9f8ee6..76ddeda059a 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -154,7 +154,17 @@
"imagesWithCount_other": "{{count}} images",
"assetsWithCount_one": "{{count}} asset",
"assetsWithCount_other": "{{count}} assets",
- "updateBoardError": "Error updating board"
+ "updateBoardError": "Error updating board",
+ "setBoardVisibility": "Set Board Visibility",
+ "setVisibilityPrivate": "Set Private",
+ "setVisibilityShared": "Set Shared",
+ "setVisibilityPublic": "Set Public",
+ "visibilityPrivate": "Private",
+ "visibilityShared": "Shared",
+ "visibilityPublic": "Public",
+ "visibilityBadgeShared": "Shared board",
+ "visibilityBadgePublic": "Public board",
+ "updateBoardVisibilityError": "Error updating board visibility"
},
"accordions": {
"generation": {
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
index 5cc25f6c038..9b6ace398ee 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
@@ -2,13 +2,23 @@ import type { ContextMenuProps } from '@invoke-ai/ui-library';
import { ContextMenu, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { $boardToDelete } from 'features/gallery/components/Boards/DeleteBoardModal';
import { selectAutoAddBoardId, selectAutoAssignBoardOnClick } from 'features/gallery/store/gallerySelectors';
import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice';
import { toast } from 'features/toast/toast';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { PiArchiveBold, PiArchiveFill, PiDownloadBold, PiPlusBold, PiTrashSimpleBold } from 'react-icons/pi';
+import {
+ PiArchiveBold,
+ PiArchiveFill,
+ PiDownloadBold,
+ PiGlobeBold,
+ PiLockBold,
+ PiPlusBold,
+ PiShareNetworkBold,
+ PiTrashSimpleBold,
+} from 'react-icons/pi';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import { useBulkDownloadImagesMutation } from 'services/api/endpoints/images';
import { useBoardName } from 'services/api/hooks/useBoardName';
@@ -23,6 +33,7 @@ const BoardContextMenu = ({ board, children }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick);
+ const currentUser = useAppSelector(selectCurrentUser);
const selectIsSelectedForAutoAdd = useMemo(
() => createSelector(selectAutoAddBoardId, (autoAddBoardId) => board.board_id === autoAddBoardId),
[board.board_id]
@@ -35,6 +46,10 @@ const BoardContextMenu = ({ board, children }: Props) => {
const [bulkDownload] = useBulkDownloadImagesMutation();
+ // Only the board owner or admin can modify visibility
+ const canChangeVisibility =
+ currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id);
+
const handleSetAutoAdd = useCallback(() => {
dispatch(autoAddBoardIdChanged(board.board_id));
}, [board.board_id, dispatch]);
@@ -64,6 +79,35 @@ const BoardContextMenu = ({ board, children }: Props) => {
});
}, [board.board_id, updateBoard]);
+ const handleSetVisibility = useCallback(
+ async (visibility: 'private' | 'shared' | 'public') => {
+ try {
+ await updateBoard({
+ board_id: board.board_id,
+ changes: { board_visibility: visibility },
+ }).unwrap();
+ } catch {
+ toast({ status: 'error', title: t('boards.updateBoardVisibilityError') });
+ }
+ },
+ [board.board_id, t, updateBoard]
+ );
+
+ const handleSetVisibilityPrivate = useCallback(
+ () => handleSetVisibility('private'),
+ [handleSetVisibility]
+ );
+
+ const handleSetVisibilityShared = useCallback(
+ () => handleSetVisibility('shared'),
+ [handleSetVisibility]
+ );
+
+ const handleSetVisibilityPublic = useCallback(
+ () => handleSetVisibility('public'),
+ [handleSetVisibility]
+ );
+
const setAsBoardToDelete = useCallback(() => {
$boardToDelete.set(board);
}, [board]);
@@ -94,6 +138,32 @@ const BoardContextMenu = ({ board, children }: Props) => {
)}
+ {canChangeVisibility && (
+ <>
+ }
+ onClick={handleSetVisibilityPrivate}
+ isDisabled={board.board_visibility === 'private'}
+ >
+ {t('boards.setVisibilityPrivate')}
+
+ }
+ onClick={handleSetVisibilityShared}
+ isDisabled={board.board_visibility === 'shared'}
+ >
+ {t('boards.setVisibilityShared')}
+
+ }
+ onClick={handleSetVisibilityPublic}
+ isDisabled={board.board_visibility === 'public'}
+ >
+ {t('boards.setVisibilityPublic')}
+
+ >
+ )}
+
} onClick={setAsBoardToDelete} isDestructive>
{t('boards.deleteBoard')}
@@ -108,8 +178,13 @@ const BoardContextMenu = ({ board, children }: Props) => {
t,
handleBulkDownload,
board.archived,
+ board.board_visibility,
handleUnarchive,
handleArchive,
+ canChangeVisibility,
+ handleSetVisibilityPrivate,
+ handleSetVisibilityShared,
+ handleSetVisibilityPublic,
setAsBoardToDelete,
]
);
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
index 4d821f819c6..ee2fd077167 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
@@ -18,7 +18,7 @@ import {
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { PiArchiveBold, PiImageSquare } from 'react-icons/pi';
+import { PiArchiveBold, PiGlobeBold, PiImageSquare, PiShareNetworkBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { BoardDTO } from 'services/api/types';
@@ -99,6 +99,20 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
{autoAddBoardId === board.board_id && }
{board.archived && }
+ {board.board_visibility === 'shared' && (
+
+
+
+
+
+ )}
+ {board.board_visibility === 'public' && (
+
+
+
+
+
+ )}
{board.image_count} | {board.asset_count}
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 31cc6ad6c51..b4d17ea3697 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -3046,6 +3046,8 @@ export type components = {
* @description Whether or not the board is archived
*/
archived?: boolean | null;
+ /** @description The visibility of the board. */
+ board_visibility?: components["schemas"]["BoardVisibility"] | null;
};
/**
* BoardDTO
@@ -3107,6 +3109,8 @@ export type components = {
* @description The username of the board owner (for admin view).
*/
owner_username?: string | null;
+ /** @description The visibility of the board. */
+ board_visibility: components["schemas"]["BoardVisibility"];
};
/**
* BoardField
@@ -3125,6 +3129,12 @@ export type components = {
* @enum {string}
*/
BoardRecordOrderBy: "created_at" | "board_name";
+ /**
+ * BoardVisibility
+ * @description The visibility options for a board.
+ * @enum {string}
+ */
+ BoardVisibility: "private" | "shared" | "public";
/** Body_add_image_to_board */
Body_add_image_to_board: {
/**
diff --git a/tests/app/routers/test_boards_multiuser.py b/tests/app/routers/test_boards_multiuser.py
index d5c48481567..ab297550c9e 100644
--- a/tests/app/routers/test_boards_multiuser.py
+++ b/tests/app/routers/test_boards_multiuser.py
@@ -457,3 +457,223 @@ def test_enqueue_batch_requires_auth(enable_multiuser_for_tests: Any, client: Te
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+# ---------------------------------------------------------------------------
+# Board visibility tests
+# ---------------------------------------------------------------------------
+
+
+def test_board_created_with_private_visibility(client: TestClient, user1_token: str):
+ """Test that newly created boards default to private visibility."""
+ create = client.post(
+ "/api/v1/boards/?board_name=Visibility+Default+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ data = create.json()
+ assert data["board_visibility"] == "private"
+
+
+def test_set_board_visibility_shared(client: TestClient, user1_token: str):
+ """Test that the board owner can set their board to shared."""
+ create = client.post(
+ "/api/v1/boards/?board_name=Shared+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ response = client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "shared"},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.json()["board_visibility"] == "shared"
+
+
+def test_set_board_visibility_public(client: TestClient, user1_token: str):
+ """Test that the board owner can set their board to public."""
+ create = client.post(
+ "/api/v1/boards/?board_name=Public+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ response = client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "public"},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.json()["board_visibility"] == "public"
+
+
+def test_shared_board_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
+ """Test that a shared board is accessible to other authenticated users."""
+ # user1 creates a board and sets it to shared
+ create = client.post(
+ "/api/v1/boards/?board_name=User1+Shared+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "shared"},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # user2 should be able to access the shared board
+ response = client.get(
+ f"/api/v1/boards/{board_id}",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["board_id"] == board_id
+
+
+def test_public_board_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
+ """Test that a public board is accessible to other authenticated users."""
+ # user1 creates a board and sets it to public
+ create = client.post(
+ "/api/v1/boards/?board_name=User1+Public+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "public"},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # user2 should be able to access the public board
+ response = client.get(
+ f"/api/v1/boards/{board_id}",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["board_id"] == board_id
+
+
+def test_shared_board_appears_in_other_user_list(client: TestClient, user1_token: str, user2_token: str):
+ """Test that shared boards appear in other users' board listings."""
+ # user1 creates and shares a board
+ create = client.post(
+ "/api/v1/boards/?board_name=User1+Listed+Shared+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "shared"},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # user2 should see the shared board in their listing
+ response = client.get(
+ "/api/v1/boards/?all=true",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ board_ids = [b["board_id"] for b in response.json()]
+ assert board_id in board_ids
+
+
+def test_private_board_not_visible_after_privacy_change(client: TestClient, user1_token: str, user2_token: str):
+ """Test that reverting a board from shared to private hides it from other users."""
+ # user1 creates a board, makes it shared, then reverts to private
+ create = client.post(
+ "/api/v1/boards/?board_name=Reverted+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "shared"},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "private"},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # user2 should not be able to access the now-private board
+ response = client.get(
+ f"/api/v1/boards/{board_id}",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_non_owner_cannot_change_board_visibility(client: TestClient, user1_token: str, user2_token: str):
+ """Test that a non-owner cannot change a board's visibility."""
+ # user1 creates a board
+ create = client.post(
+ "/api/v1/boards/?board_name=User1+Private+Locked+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ # user2 tries to make it public - should be forbidden
+ response = client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "public"},
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_shared_board_image_names_visible_to_other_users(
+ client: TestClient, user1_token: str, user2_token: str
+):
+ """Test that image names for shared boards are accessible to other users."""
+ create = client.post(
+ "/api/v1/boards/?board_name=User1+Shared+Images+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "shared"},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # user2 can access image names for a shared board
+ response = client.get(
+ f"/api/v1/boards/{board_id}/image_names",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+
+def test_admin_can_change_any_board_visibility(client: TestClient, admin_token: str, user1_token: str):
+ """Test that an admin can change the visibility of any user's board."""
+ create = client.post(
+ "/api/v1/boards/?board_name=User1+Board+For+Admin+Visibility",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ # Admin sets it to public
+ response = client.patch(
+ f"/api/v1/boards/{board_id}",
+ json={"board_visibility": "public"},
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.json()["board_visibility"] == "public"
From f38d1abc1a4feefea17d825949e6aab198763647 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 22:46:10 +0000
Subject: [PATCH 007/100] Enforce read-only access for non-owners of
shared/public boards in UI
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
.../components/Boards/BoardContextMenu.tsx | 12 ++++++-
.../Boards/BoardsList/BoardEditableTitle.tsx | 8 +++--
.../Boards/BoardsList/GalleryBoard.tsx | 10 +++++-
.../MenuItems/ContextMenuItemChangeBoard.tsx | 6 +++-
.../MenuItems/ContextMenuItemDeleteImage.tsx | 8 +++++
.../MultipleSelectionMenuItems.tsx | 13 ++++++--
.../ImageGrid/GalleryItemDeleteIconButton.tsx | 6 +++-
.../components/InvokeQueueBackButton.tsx | 6 +++-
.../src/services/api/hooks/useAutoAddBoard.ts | 21 ++++++++++++
.../src/services/api/hooks/useBoardAccess.ts | 32 +++++++++++++++++++
.../services/api/hooks/useSelectedBoard.ts | 21 ++++++++++++
11 files changed, 133 insertions(+), 10 deletions(-)
create mode 100644 invokeai/frontend/web/src/services/api/hooks/useAutoAddBoard.ts
create mode 100644 invokeai/frontend/web/src/services/api/hooks/useBoardAccess.ts
create mode 100644 invokeai/frontend/web/src/services/api/hooks/useSelectedBoard.ts
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
index 9b6ace398ee..a4a4ae307a3 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
@@ -21,6 +21,7 @@ import {
} from 'react-icons/pi';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import { useBulkDownloadImagesMutation } from 'services/api/endpoints/images';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
import { useBoardName } from 'services/api/hooks/useBoardName';
import type { BoardDTO } from 'services/api/types';
@@ -50,6 +51,8 @@ const BoardContextMenu = ({ board, children }: Props) => {
const canChangeVisibility =
currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id);
+ const { canDeleteBoard } = useBoardAccess(board);
+
const handleSetAutoAdd = useCallback(() => {
dispatch(autoAddBoardIdChanged(board.board_id));
}, [board.board_id, dispatch]);
@@ -164,7 +167,13 @@ const BoardContextMenu = ({ board, children }: Props) => {
>
)}
- } onClick={setAsBoardToDelete} isDestructive>
+ }
+ onClick={setAsBoardToDelete}
+ isDestructive
+ isDisabled={!canDeleteBoard}
+ >
{t('boards.deleteBoard')}
@@ -185,6 +194,7 @@ const BoardContextMenu = ({ board, children }: Props) => {
handleSetVisibilityPrivate,
handleSetVisibilityShared,
handleSetVisibilityPublic,
+ canDeleteBoard,
setAsBoardToDelete,
]
);
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx
index a78f5706e10..0e4216c3cb2 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx
@@ -7,6 +7,7 @@ import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPencilBold } from 'react-icons/pi';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
import type { BoardDTO } from 'services/api/types';
type Props = {
@@ -19,6 +20,7 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
const isHovering = useBoolean(false);
const inputRef = useRef(null);
const [updateBoard, updateBoardResult] = useUpdateBoardMutation();
+ const { canRenameBoard } = useBoardAccess(board);
const onChange = useCallback(
async (board_name: string) => {
@@ -51,13 +53,13 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
fontWeight="semibold"
userSelect="none"
color={isSelected ? 'base.100' : 'base.300'}
- onDoubleClick={editable.startEditing}
- cursor="text"
+ onDoubleClick={canRenameBoard ? editable.startEditing : undefined}
+ cursor={canRenameBoard ? 'text' : 'default'}
noOfLines={1}
>
{editable.value}
- {isHovering.isTrue && (
+ {canRenameBoard && isHovering.isTrue && (
}
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
index ee2fd077167..10fbe618322 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
@@ -20,6 +20,7 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArchiveBold, PiGlobeBold, PiImageSquare, PiShareNetworkBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
import type { BoardDTO } from 'services/api/types';
const _hover: SystemStyleObject = {
@@ -62,6 +63,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
const showOwner = currentUser?.is_admin && board.owner_username;
+ const { canWriteImages } = useBoardAccess(board);
+
return (
@@ -122,7 +125,12 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
)}
-
+
);
};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx
index 71764870153..f5c044132e5 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx
@@ -5,11 +5,15 @@ import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFoldersBold } from 'react-icons/pi';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
+import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
export const ContextMenuItemChangeBoard = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const imageDTO = useImageDTOContext();
+ const selectedBoard = useSelectedBoard();
+ const { canWriteImages } = useBoardAccess(selectedBoard);
const onClick = useCallback(() => {
dispatch(imagesToChangeSelected([imageDTO.image_name]));
@@ -17,7 +21,7 @@ export const ContextMenuItemChangeBoard = memo(() => {
}, [dispatch, imageDTO]);
return (
- } onClickCapture={onClick}>
+ } onClickCapture={onClick} isDisabled={!canWriteImages}>
{t('boards.changeBoard')}
);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx
index e20221f3423..5dfa7116b17 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx
@@ -4,11 +4,15 @@ import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
+import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
export const ContextMenuItemDeleteImage = memo(() => {
const { t } = useTranslation();
const deleteImageModal = useDeleteImageModalApi();
const imageDTO = useImageDTOContext();
+ const selectedBoard = useSelectedBoard();
+ const { canWriteImages } = useBoardAccess(selectedBoard);
const onClick = useCallback(async () => {
try {
@@ -18,6 +22,10 @@ export const ContextMenuItemDeleteImage = memo(() => {
}
}, [deleteImageModal, imageDTO]);
+ if (!canWriteImages) {
+ return null;
+ }
+
return (
}
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx
index d148332943c..ee3c8e4e985 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx
@@ -10,12 +10,16 @@ import {
useStarImagesMutation,
useUnstarImagesMutation,
} from 'services/api/endpoints/images';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
+import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
const MultipleSelectionMenuItems = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const selection = useAppSelector((s) => s.gallery.selection);
const deleteImageModal = useDeleteImageModalApi();
+ const selectedBoard = useSelectedBoard();
+ const { canWriteImages } = useBoardAccess(selectedBoard);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
@@ -53,11 +57,16 @@ const MultipleSelectionMenuItems = () => {
} onClickCapture={handleBulkDownload}>
{t('gallery.downloadSelection')}
- } onClickCapture={handleChangeBoard}>
+ } onClickCapture={handleChangeBoard} isDisabled={!canWriteImages}>
{t('boards.changeBoard')}
- } onClickCapture={handleDeleteSelection}>
+ }
+ onClickCapture={handleDeleteSelection}
+ isDisabled={!canWriteImages}
+ >
{t('gallery.deleteSelection')}
>
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx
index 0a97bf819de..612e6361b14 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx
@@ -5,6 +5,8 @@ import type { MouseEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
+import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
import type { ImageDTO } from 'services/api/types';
type Props = {
@@ -15,6 +17,8 @@ export const GalleryItemDeleteIconButton = memo(({ imageDTO }: Props) => {
const shift = useShiftModifier();
const { t } = useTranslation();
const deleteImageModal = useDeleteImageModalApi();
+ const selectedBoard = useSelectedBoard();
+ const { canWriteImages } = useBoardAccess(selectedBoard);
const onClick = useCallback(
(e: MouseEvent) => {
@@ -24,7 +28,7 @@ export const GalleryItemDeleteIconButton = memo(({ imageDTO }: Props) => {
[deleteImageModal, imageDTO]
);
- if (!shift) {
+ if (!shift || !canWriteImages) {
return null;
}
diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx
index b175e4d8b09..a363d159e1d 100644
--- a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx
@@ -5,6 +5,8 @@ import { QueueIterationsNumberInput } from 'features/queue/components/QueueItera
import { useInvoke } from 'features/queue/hooks/useInvoke';
import { memo } from 'react';
import { PiLightningFill, PiSparkleFill } from 'react-icons/pi';
+import { useAutoAddBoard } from 'services/api/hooks/useAutoAddBoard';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
import { InvokeButtonTooltip } from './InvokeButtonTooltip/InvokeButtonTooltip';
@@ -14,6 +16,8 @@ export const InvokeButton = memo(() => {
const queue = useInvoke();
const shift = useShiftModifier();
const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading);
+ const autoAddBoard = useAutoAddBoard();
+ const { canWriteImages } = useBoardAccess(autoAddBoard);
return (
@@ -23,7 +27,7 @@ export const InvokeButton = memo(() => {
onClick={shift ? queue.enqueueFront : queue.enqueueBack}
isLoading={queue.isLoading || isLoadingDynamicPrompts}
loadingText={invoke}
- isDisabled={queue.isDisabled}
+ isDisabled={queue.isDisabled || !canWriteImages}
rightIcon={shift ? : }
variant="solid"
colorScheme="invokeYellow"
diff --git a/invokeai/frontend/web/src/services/api/hooks/useAutoAddBoard.ts b/invokeai/frontend/web/src/services/api/hooks/useAutoAddBoard.ts
new file mode 100644
index 00000000000..1ae22270079
--- /dev/null
+++ b/invokeai/frontend/web/src/services/api/hooks/useAutoAddBoard.ts
@@ -0,0 +1,21 @@
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
+import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
+
+/**
+ * Returns the `BoardDTO` for the board currently configured as the auto-add
+ * destination, or `null` when it is set to "Uncategorized" (`boardId === 'none'`)
+ * or when the board list has not yet loaded.
+ */
+export const useAutoAddBoard = () => {
+ const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
+ const { board } = useListAllBoardsQuery(
+ { include_archived: true },
+ {
+ selectFromResult: ({ data }) => ({
+ board: data?.find((b) => b.board_id === autoAddBoardId) ?? null,
+ }),
+ }
+ );
+ return board;
+};
diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardAccess.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardAccess.ts
new file mode 100644
index 00000000000..9a222024255
--- /dev/null
+++ b/invokeai/frontend/web/src/services/api/hooks/useBoardAccess.ts
@@ -0,0 +1,32 @@
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
+import type { BoardDTO } from 'services/api/types';
+
+/**
+ * Returns permission flags for the given board based on the current user:
+ * - `canWriteImages`: can add / delete images in the board
+ * (owner or admin always; non-owner allowed only for public boards)
+ * - `canRenameBoard`: can rename the board (owner or admin only)
+ * - `canDeleteBoard`: can delete the board (owner or admin only)
+ *
+ * When `board` is null/undefined (e.g. "uncategorized"), all permissions are
+ * granted so that existing behaviour is preserved.
+ *
+ * When `currentUser` is null the app is running without authentication
+ * (single-user mode), so full access is granted unconditionally.
+ */
+export const useBoardAccess = (board: BoardDTO | null | undefined) => {
+ const currentUser = useAppSelector(selectCurrentUser);
+
+ if (!board) {
+ return { canWriteImages: true, canRenameBoard: true, canDeleteBoard: true };
+ }
+
+ const isOwnerOrAdmin = !currentUser || currentUser.is_admin || board.user_id === currentUser.user_id;
+
+ return {
+ canWriteImages: isOwnerOrAdmin || board.board_visibility === 'public',
+ canRenameBoard: isOwnerOrAdmin,
+ canDeleteBoard: isOwnerOrAdmin,
+ };
+};
diff --git a/invokeai/frontend/web/src/services/api/hooks/useSelectedBoard.ts b/invokeai/frontend/web/src/services/api/hooks/useSelectedBoard.ts
new file mode 100644
index 00000000000..40c6d77f37f
--- /dev/null
+++ b/invokeai/frontend/web/src/services/api/hooks/useSelectedBoard.ts
@@ -0,0 +1,21 @@
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
+import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
+
+/**
+ * Returns the `BoardDTO` for the currently selected board, or `null` when the
+ * user is viewing "Uncategorized" (`boardId === 'none'`) or when the board list
+ * has not yet loaded.
+ */
+export const useSelectedBoard = () => {
+ const selectedBoardId = useAppSelector(selectSelectedBoardId);
+ const { board } = useListAllBoardsQuery(
+ { include_archived: true },
+ {
+ selectFromResult: ({ data }) => ({
+ board: data?.find((b) => b.board_id === selectedBoardId) ?? null,
+ }),
+ }
+ );
+ return board;
+};
From 9f8f7a1f022a38677cb7f137a713011d8cdc0cc1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 10 Mar 2026 02:35:45 +0000
Subject: [PATCH 008/100] Fix remaining board access enforcement: invoke icon,
drag-out, change-board filter, archive
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
.../migrations/migration_29.py | 12 ++---
.../components/ChangeBoardModal.tsx | 15 +++++-
.../components/Boards/BoardContextMenu.tsx | 22 +++------
.../components/ImageGrid/GalleryImage.tsx | 47 ++++++++++++-------
.../components/FloatingLeftPanelButtons.tsx | 6 ++-
.../frontend/web/src/services/api/schema.ts | 7 ++-
6 files changed, 62 insertions(+), 47 deletions(-)
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py
index c3fa48e6377..c9eb7c901ba 100644
--- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py
@@ -33,17 +33,11 @@ def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
columns = [row[1] for row in cursor.fetchall()]
if "board_visibility" not in columns:
- cursor.execute(
- "ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';"
- )
- cursor.execute(
- "CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);"
- )
+ cursor.execute("ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);")
# Migrate existing is_public = 1 boards to 'public'
if "is_public" in columns:
- cursor.execute(
- "UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;"
- )
+ cursor.execute("UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;")
def build_migration_29() -> Migration:
diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx
index 00217eb7963..5ac6ffcb7c9 100644
--- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx
+++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx
@@ -3,6 +3,7 @@ import { Combobox, ConfirmationAlertDialog, Flex, FormControl, Text } from '@inv
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import {
changeBoardReset,
isModalOpenChanged,
@@ -13,6 +14,7 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images';
+import type { BoardDTO } from 'services/api/types';
const selectImagesToChange = createSelector(
selectChangeBoardModalSlice,
@@ -28,6 +30,7 @@ const ChangeBoardModal = () => {
useAssertSingleton('ChangeBoardModal');
const dispatch = useAppDispatch();
const currentBoardId = useAppSelector(selectSelectedBoardId);
+ const currentUser = useAppSelector(selectCurrentUser);
const [selectedBoardId, setSelectedBoardId] = useState();
const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true });
const isModalOpen = useAppSelector(selectIsModalOpen);
@@ -36,10 +39,20 @@ const ChangeBoardModal = () => {
const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation();
const { t } = useTranslation();
+ // Returns true if the current user can write images to the given board.
+ const canWriteToBoard = useCallback(
+ (board: BoardDTO): boolean => {
+ const isOwnerOrAdmin = !currentUser || currentUser.is_admin || board.user_id === currentUser.user_id;
+ return isOwnerOrAdmin || board.board_visibility === 'public';
+ },
+ [currentUser]
+ );
+
const options = useMemo(() => {
return [{ label: t('boards.uncategorized'), value: 'none' }]
.concat(
(boards ?? [])
+ .filter(canWriteToBoard)
.map((board) => ({
label: board.board_name,
value: board.board_id,
@@ -47,7 +60,7 @@ const ChangeBoardModal = () => {
.sort((a, b) => a.label.localeCompare(b.label))
)
.filter((board) => board.value !== currentBoardId);
- }, [boards, currentBoardId, t]);
+ }, [boards, canWriteToBoard, currentBoardId, t]);
const value = useMemo(() => options.find((o) => o.value === selectedBoardId), [options, selectedBoardId]);
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
index a4a4ae307a3..d10dde6ee44 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx
@@ -48,8 +48,7 @@ const BoardContextMenu = ({ board, children }: Props) => {
const [bulkDownload] = useBulkDownloadImagesMutation();
// Only the board owner or admin can modify visibility
- const canChangeVisibility =
- currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id);
+ const canChangeVisibility = currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id);
const { canDeleteBoard } = useBoardAccess(board);
@@ -96,20 +95,11 @@ const BoardContextMenu = ({ board, children }: Props) => {
[board.board_id, t, updateBoard]
);
- const handleSetVisibilityPrivate = useCallback(
- () => handleSetVisibility('private'),
- [handleSetVisibility]
- );
+ const handleSetVisibilityPrivate = useCallback(() => handleSetVisibility('private'), [handleSetVisibility]);
- const handleSetVisibilityShared = useCallback(
- () => handleSetVisibility('shared'),
- [handleSetVisibility]
- );
+ const handleSetVisibilityShared = useCallback(() => handleSetVisibility('shared'), [handleSetVisibility]);
- const handleSetVisibilityPublic = useCallback(
- () => handleSetVisibility('public'),
- [handleSetVisibility]
- );
+ const handleSetVisibilityPublic = useCallback(() => handleSetVisibility('public'), [handleSetVisibility]);
const setAsBoardToDelete = useCallback(() => {
$boardToDelete.set(board);
@@ -130,13 +120,13 @@ const BoardContextMenu = ({ board, children }: Props) => {
{board.archived && (
- } onClick={handleUnarchive}>
+ } onClick={handleUnarchive} isDisabled={!canDeleteBoard}>
{t('boards.unarchiveBoard')}
)}
{!board.archived && (
- } onClick={handleArchive}>
+ } onClick={handleArchive} isDisabled={!canDeleteBoard}>
{t('boards.archiveBoard')}
)}
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
index ccd58992ef6..8236ffcf622 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
@@ -26,6 +26,8 @@ import type { MouseEvent, MouseEventHandler } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiImageBold } from 'react-icons/pi';
import { imagesApi } from 'services/api/endpoints/images';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
+import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
import type { ImageDTO } from 'services/api/types';
import { galleryItemContainerSX } from './galleryItemContainerSX';
@@ -102,12 +104,37 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
[imageDTO.image_name]
);
const isSelected = useAppSelector(selectIsSelected);
+ const selectedBoard = useSelectedBoard();
+ const { canWriteImages: canDragFromBoard } = useBoardAccess(selectedBoard);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
+
+ const monitorBinding = monitorForElements({
+ // This is a "global" drag start event, meaning that it is called for all drag events.
+ onDragStart: ({ source }) => {
+ // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
+ // selection. This is called for all drag events.
+ if (
+ multipleImageDndSource.typeGuard(source.data) &&
+ source.data.payload.image_names.includes(imageDTO.image_name)
+ ) {
+ setIsDragging(true);
+ }
+ },
+ onDrop: () => {
+ // Always set the dragging state to false when a drop event occurs.
+ setIsDragging(false);
+ },
+ });
+
+ if (!canDragFromBoard) {
+ return combine(firefoxDndFix(element), monitorBinding);
+ }
+
return combine(
firefoxDndFix(element),
draggable({
@@ -153,25 +180,9 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
}
},
}),
- monitorForElements({
- // This is a "global" drag start event, meaning that it is called for all drag events.
- onDragStart: ({ source }) => {
- // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
- // selection. This is called for all drag events.
- if (
- multipleImageDndSource.typeGuard(source.data) &&
- source.data.payload.image_names.includes(imageDTO.image_name)
- ) {
- setIsDragging(true);
- }
- },
- onDrop: () => {
- // Always set the dragging state to false when a drop event occurs.
- setIsDragging(false);
- },
- })
+ monitorBinding
);
- }, [imageDTO, store]);
+ }, [imageDTO, store, canDragFromBoard]);
const [isHovered, setIsHovered] = useState(false);
diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx
index 81e8930e401..c9620d84ac9 100644
--- a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx
@@ -17,6 +17,8 @@ import {
PiXCircle,
} from 'react-icons/pi';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
+import { useAutoAddBoard } from 'services/api/hooks/useAutoAddBoard';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
export const FloatingLeftPanelButtons = memo(() => {
return (
@@ -71,6 +73,8 @@ const InvokeIconButton = memo(() => {
const { t } = useTranslation();
const queue = useInvoke();
const shift = useShiftModifier();
+ const autoAddBoard = useAutoAddBoard();
+ const { canWriteImages } = useBoardAccess(autoAddBoard);
return (
@@ -78,7 +82,7 @@ const InvokeIconButton = memo(() => {
aria-label={t('queue.queueBack')}
onClick={shift ? queue.enqueueFront : queue.enqueueBack}
isLoading={queue.isLoading}
- isDisabled={queue.isDisabled}
+ isDisabled={queue.isDisabled || !canWriteImages}
icon={}
colorScheme="invokeYellow"
flexGrow={1}
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index b4d17ea3697..8cffa369101 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -3094,6 +3094,11 @@ export type components = {
* @description Whether or not the board is archived.
*/
archived: boolean;
+ /**
+ * @description The visibility of the board.
+ * @default private
+ */
+ board_visibility?: components["schemas"]["BoardVisibility"];
/**
* Image Count
* @description The number of images in the board.
@@ -3109,8 +3114,6 @@ export type components = {
* @description The username of the board owner (for admin view).
*/
owner_username?: string | null;
- /** @description The visibility of the board. */
- board_visibility: components["schemas"]["BoardVisibility"];
};
/**
* BoardField
From f1281217e539f3086ff8bed2b04f9e8c88df06fb Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Sat, 4 Apr 2026 19:33:46 -0400
Subject: [PATCH 009/100] fix: allow drag from shared boards to non-board
targets (viewer, ref image, etc.)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previously, images in shared boards owned by another user could not be
dragged at all — the draggable setup was completely skipped in
GalleryImage.tsx when canWriteImages was false. This blocked ALL drop
targets including the viewer, reference image pane, and canvas.
Now images are always draggable. The board-move restriction is enforced
in the dnd target isValid functions instead:
- addImageToBoardDndTarget: rejects moves from shared boards the user
doesn't own (unless admin or board is public)
- removeImageFromBoardDndTarget: same check
Other drop targets (viewer, reference images, canvas, comparison, etc.)
remain fully functional for shared board images.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
invokeai/frontend/web/src/features/dnd/dnd.ts | 69 +++++++++++++++++--
.../components/ImageGrid/GalleryImage.tsx | 10 +--
2 files changed, 64 insertions(+), 15 deletions(-)
diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts
index f5e38d4b944..ee648e82ef6 100644
--- a/invokeai/frontend/web/src/features/dnd/dnd.ts
+++ b/invokeai/frontend/web/src/features/dnd/dnd.ts
@@ -434,6 +434,49 @@ export const replaceCanvasEntityObjectsWithImageDndTarget: DndTarget<
//#endregion
//#region Add To Board
+/**
+ * Check whether the current user can move images out of their source board.
+ * Returns false if the source board is a shared board not owned by the current user
+ * (and the user is not an admin). In that case, images can be viewed/used but not moved.
+ */
+const canMoveFromSourceBoard = (sourceBoardId: BoardId, getState: AppGetState): boolean => {
+ const state = getState();
+ // In single-user mode (no auth), always allow
+ const currentUser = state.auth?.user;
+ if (!currentUser) {
+ return true;
+ }
+ // Admins can always move
+ if (currentUser.is_admin) {
+ return true;
+ }
+ // "Uncategorized" (none) — user's own uncategorized images, allow
+ if (sourceBoardId === 'none') {
+ return true;
+ }
+ // Look up the board from the RTK Query cache
+ const boardsQueryState = state.api?.queries;
+ if (boardsQueryState) {
+ for (const query of Object.values(boardsQueryState)) {
+ if (query?.data && Array.isArray(query.data)) {
+ const board = (query.data as Array<{ board_id: string; user_id?: string; board_visibility?: string }>).find(
+ (b) => b.board_id === sourceBoardId
+ );
+ if (board) {
+ // Owner can always move
+ if (board.user_id === currentUser.user_id) {
+ return true;
+ }
+ // Non-owner can only move from public boards
+ return board.board_visibility === 'public';
+ }
+ }
+ }
+ }
+ // Board not found in cache — allow by default to avoid blocking legitimate operations
+ return true;
+};
+
const _addToBoard = buildTypeAndKey('add-to-board');
export type AddImageToBoardDndTargetData = DndData<
typeof _addToBoard.type,
@@ -447,16 +490,23 @@ export const addImageToBoardDndTarget: DndTarget<
..._addToBoard,
typeGuard: buildTypeGuard(_addToBoard.key),
getData: buildGetData(_addToBoard.key, _addToBoard.type),
- isValid: ({ sourceData, targetData }) => {
+ isValid: ({ sourceData, targetData, getState }) => {
if (singleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
const destinationBoard = targetData.payload.boardId;
- return currentBoard !== destinationBoard;
+ if (currentBoard === destinationBoard) {
+ return false;
+ }
+ // Don't allow moving images from shared boards the user doesn't own
+ return canMoveFromSourceBoard(currentBoard, getState);
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.board_id;
const destinationBoard = targetData.payload.boardId;
- return currentBoard !== destinationBoard;
+ if (currentBoard === destinationBoard) {
+ return false;
+ }
+ return canMoveFromSourceBoard(currentBoard, getState);
}
return false;
},
@@ -491,15 +541,22 @@ export const removeImageFromBoardDndTarget: DndTarget<
..._removeFromBoard,
typeGuard: buildTypeGuard(_removeFromBoard.key),
getData: buildGetData(_removeFromBoard.key, _removeFromBoard.type),
- isValid: ({ sourceData }) => {
+ isValid: ({ sourceData, getState }) => {
if (singleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
- return currentBoard !== 'none';
+ if (currentBoard === 'none') {
+ return false;
+ }
+ // Don't allow removing images from shared boards the user doesn't own
+ return canMoveFromSourceBoard(currentBoard, getState);
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.board_id;
- return currentBoard !== 'none';
+ if (currentBoard === 'none') {
+ return false;
+ }
+ return canMoveFromSourceBoard(currentBoard, getState);
}
return false;
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
index 8236ffcf622..af1d376887b 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
@@ -26,8 +26,6 @@ import type { MouseEvent, MouseEventHandler } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiImageBold } from 'react-icons/pi';
import { imagesApi } from 'services/api/endpoints/images';
-import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
-import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
import type { ImageDTO } from 'services/api/types';
import { galleryItemContainerSX } from './galleryItemContainerSX';
@@ -104,8 +102,6 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
[imageDTO.image_name]
);
const isSelected = useAppSelector(selectIsSelected);
- const selectedBoard = useSelectedBoard();
- const { canWriteImages: canDragFromBoard } = useBoardAccess(selectedBoard);
useEffect(() => {
const element = ref.current;
@@ -131,10 +127,6 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
},
});
- if (!canDragFromBoard) {
- return combine(firefoxDndFix(element), monitorBinding);
- }
-
return combine(
firefoxDndFix(element),
draggable({
@@ -182,7 +174,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
}),
monitorBinding
);
- }, [imageDTO, store, canDragFromBoard]);
+ }, [imageDTO, store]);
const [isHovered, setIsHovered] = useState(false);
From ac4ef09787a3ce53c507f0e3271490dddff0e0f9 Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Sun, 5 Apr 2026 23:38:39 -0400
Subject: [PATCH 010/100] fix(security): add auth requirement to all sensitive
routes in multimodal mode
---
invokeai/app/api/routers/auth.py | 5 +-
invokeai/app/api/routers/board_images.py | 29 +-
invokeai/app/api/routers/images.py | 140 +++-
invokeai/app/api/routers/recall_parameters.py | 3 +
invokeai/app/api/routers/session_queue.py | 15 +-
invokeai/app/api/routers/workflows.py | 16 +-
.../image_records/image_records_base.py | 5 +
.../image_records/image_records_sqlite.py | 14 +
tests/app/routers/test_images.py | 16 +-
.../routers/test_multiuser_authorization.py | 725 ++++++++++++++++++
10 files changed, 950 insertions(+), 18 deletions(-)
create mode 100644 tests/app/routers/test_multiuser_authorization.py
diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py
index 8008da2bf53..26440cd08b1 100644
--- a/invokeai/app/api/routers/auth.py
+++ b/invokeai/app/api/routers/auth.py
@@ -104,7 +104,10 @@ async def get_setup_status() -> SetupStatusResponse:
# In multiuser mode, check if an admin exists
user_service = ApiDependencies.invoker.services.users
setup_required = not user_service.has_admin()
- admin_email = user_service.get_admin_email()
+
+ # Only expose admin_email during initial setup to avoid leaking
+ # administrator identity on public deployments.
+ admin_email = user_service.get_admin_email() if setup_required else None
return SetupStatusResponse(
setup_required=setup_required,
diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py
index cb5e0ab51ab..fbd1c474bf7 100644
--- a/invokeai/app/api/routers/board_images.py
+++ b/invokeai/app/api/routers/board_images.py
@@ -1,12 +1,23 @@
from fastapi import Body, HTTPException
from fastapi.routing import APIRouter
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult
board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
+def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user may not mutate the given board."""
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+ if not current_user.is_admin and board.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to modify this board")
+
+
@board_images_router.post(
"/",
operation_id="add_image_to_board",
@@ -17,10 +28,12 @@
response_model=AddImagesToBoardResult,
)
async def add_image_to_board(
+ current_user: CurrentUserOrDefault,
board_id: str = Body(description="The id of the board to add to"),
image_name: str = Body(description="The name of the image to add"),
) -> AddImagesToBoardResult:
"""Creates a board_image"""
+ _assert_board_write_access(board_id, current_user)
try:
added_images: set[str] = set()
affected_boards: set[str] = set()
@@ -48,13 +61,16 @@ async def add_image_to_board(
response_model=RemoveImagesFromBoardResult,
)
async def remove_image_from_board(
+ current_user: CurrentUserOrDefault,
image_name: str = Body(description="The name of the image to remove", embed=True),
) -> RemoveImagesFromBoardResult:
"""Removes an image from its board, if it had one"""
try:
+ old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
+ if old_board_id != "none":
+ _assert_board_write_access(old_board_id, current_user)
removed_images: set[str] = set()
affected_boards: set[str] = set()
- old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
removed_images.add(image_name)
affected_boards.add("none")
@@ -64,6 +80,8 @@ async def remove_image_from_board(
affected_boards=list(affected_boards),
)
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove image from board")
@@ -78,10 +96,12 @@ async def remove_image_from_board(
response_model=AddImagesToBoardResult,
)
async def add_images_to_board(
+ current_user: CurrentUserOrDefault,
board_id: str = Body(description="The id of the board to add to"),
image_names: list[str] = Body(description="The names of the images to add", embed=True),
) -> AddImagesToBoardResult:
"""Adds a list of images to a board"""
+ _assert_board_write_access(board_id, current_user)
try:
added_images: set[str] = set()
affected_boards: set[str] = set()
@@ -116,6 +136,7 @@ async def add_images_to_board(
response_model=RemoveImagesFromBoardResult,
)
async def remove_images_from_board(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The names of the images to remove", embed=True),
) -> RemoveImagesFromBoardResult:
"""Removes a list of images from their board, if they had one"""
@@ -125,15 +146,21 @@ async def remove_images_from_board(
for image_name in image_names:
try:
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
+ if old_board_id != "none":
+ _assert_board_write_access(old_board_id, current_user)
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
removed_images.add(image_name)
affected_boards.add("none")
affected_boards.add(old_board_id)
+ except HTTPException:
+ raise
except Exception:
pass
return RemoveImagesFromBoardResult(
removed_images=list(removed_images),
affected_boards=list(affected_boards),
)
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove images from board")
diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py
index 6b11762c9ec..eaf0b3d5098 100644
--- a/invokeai/app/api/routers/images.py
+++ b/invokeai/app/api/routers/images.py
@@ -38,6 +38,63 @@
IMAGE_MAX_AGE = 31536000
+def _assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user does not own the image and is not an admin.
+
+ Ownership is satisfied when ANY of these hold:
+ - The user is an admin.
+ - The user is the image's direct owner (image_records.user_id).
+ - The user owns the board the image sits on.
+ """
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+
+ # Check whether the user owns the board the image belongs to.
+ board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.user_id == current_user.user_id:
+ return
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail="Not authorized to modify this image")
+
+
+def _assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user may not view the image.
+
+ Access is granted when ANY of these hold:
+ - The user is an admin.
+ - The user owns the image.
+ - The image sits on a shared or public board.
+ """
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ if current_user.is_admin:
+ return
+
+ owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+
+ # Check whether the image's board makes it visible to other users.
+ board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public):
+ return
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail="Not authorized to access this image")
+
+
class ResizeToDimensions(BaseModel):
width: int = Field(..., gt=0)
height: int = Field(..., gt=0)
@@ -83,6 +140,15 @@ async def upload_image(
),
) -> ImageDTO:
"""Uploads an image for the current user"""
+ # If uploading into a board, verify the user owns it
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+ if not current_user.is_admin and board.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to upload to this board")
+
if not file.content_type or not file.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
@@ -165,9 +231,11 @@ async def create_image_upload_entry(
@images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult)
async def delete_image(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of the image to delete"),
) -> DeleteImagesResult:
"""Deletes an image"""
+ _assert_image_owner(image_name, current_user)
deleted_images: set[str] = set()
affected_boards: set[str] = set()
@@ -189,26 +257,30 @@ async def delete_image(
@images_router.delete("/intermediates", operation_id="clear_intermediates")
-async def clear_intermediates() -> int:
- """Clears all intermediates"""
+async def clear_intermediates(
+ current_user: CurrentUserOrDefault,
+) -> int:
+ """Clears all intermediates. Requires admin."""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Only admins can clear all intermediates")
try:
count_deleted = ApiDependencies.invoker.services.images.delete_intermediates()
return count_deleted
except Exception:
raise HTTPException(status_code=500, detail="Failed to clear intermediates")
- pass
@images_router.get("/intermediates", operation_id="get_intermediates_count")
-async def get_intermediates_count() -> int:
+async def get_intermediates_count(
+ current_user: CurrentUserOrDefault,
+) -> int:
"""Gets the count of intermediate images"""
try:
return ApiDependencies.invoker.services.images.get_intermediates_count()
except Exception:
raise HTTPException(status_code=500, detail="Failed to get intermediates")
- pass
@images_router.patch(
@@ -217,10 +289,12 @@ async def get_intermediates_count() -> int:
response_model=ImageDTO,
)
async def update_image(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of the image to update"),
image_changes: ImageRecordChanges = Body(description="The changes to apply to the image"),
) -> ImageDTO:
"""Updates an image"""
+ _assert_image_owner(image_name, current_user)
try:
return ApiDependencies.invoker.services.images.update(image_name, image_changes)
@@ -234,9 +308,11 @@ async def update_image(
response_model=ImageDTO,
)
async def get_image_dto(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of image to get"),
) -> ImageDTO:
"""Gets an image's DTO"""
+ _assert_image_read_access(image_name, current_user)
try:
return ApiDependencies.invoker.services.images.get_dto(image_name)
@@ -250,9 +326,11 @@ async def get_image_dto(
response_model=Optional[MetadataField],
)
async def get_image_metadata(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of image to get"),
) -> Optional[MetadataField]:
"""Gets an image's metadata"""
+ _assert_image_read_access(image_name, current_user)
try:
return ApiDependencies.invoker.services.images.get_metadata(image_name)
@@ -269,8 +347,11 @@ class WorkflowAndGraphResponse(BaseModel):
"/i/{image_name}/workflow", operation_id="get_image_workflow", response_model=WorkflowAndGraphResponse
)
async def get_image_workflow(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of image whose workflow to get"),
) -> WorkflowAndGraphResponse:
+ _assert_image_read_access(image_name, current_user)
+
try:
workflow = ApiDependencies.invoker.services.images.get_workflow(image_name)
graph = ApiDependencies.invoker.services.images.get_graph(image_name)
@@ -306,8 +387,12 @@ async def get_image_workflow(
async def get_image_full(
image_name: str = Path(description="The name of full-resolution image file to get"),
) -> Response:
- """Gets a full-resolution image file"""
+ """Gets a full-resolution image file.
+ This endpoint is intentionally unauthenticated because browsers load images
+ via
tags which cannot send Bearer tokens. Image names are UUIDs,
+ providing security through unguessability.
+ """
try:
path = ApiDependencies.invoker.services.images.get_path(image_name)
with open(path, "rb") as f:
@@ -335,8 +420,12 @@ async def get_image_full(
async def get_image_thumbnail(
image_name: str = Path(description="The name of thumbnail image file to get"),
) -> Response:
- """Gets a thumbnail image file"""
+ """Gets a thumbnail image file.
+ This endpoint is intentionally unauthenticated because browsers load images
+ via
tags which cannot send Bearer tokens. Image names are UUIDs,
+ providing security through unguessability.
+ """
try:
path = ApiDependencies.invoker.services.images.get_path(image_name, thumbnail=True)
with open(path, "rb") as f:
@@ -354,9 +443,11 @@ async def get_image_thumbnail(
response_model=ImageUrlsDTO,
)
async def get_image_urls(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of the image whose URL to get"),
) -> ImageUrlsDTO:
"""Gets an image and thumbnail URL"""
+ _assert_image_read_access(image_name, current_user)
try:
image_url = ApiDependencies.invoker.services.images.get_url(image_name)
@@ -410,6 +501,7 @@ async def list_image_dtos(
@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult)
async def delete_images_from_list(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The list of names of images to delete", embed=True),
) -> DeleteImagesResult:
try:
@@ -417,24 +509,31 @@ async def delete_images_from_list(
affected_boards: set[str] = set()
for image_name in image_names:
try:
+ _assert_image_owner(image_name, current_user)
image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
board_id = image_dto.board_id or "none"
ApiDependencies.invoker.services.images.delete(image_name)
deleted_images.add(image_name)
affected_boards.add(board_id)
+ except HTTPException:
+ raise
except Exception:
pass
return DeleteImagesResult(
deleted_images=list(deleted_images),
affected_boards=list(affected_boards),
)
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete images")
@images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult)
-async def delete_uncategorized_images() -> DeleteImagesResult:
- """Deletes all images that are uncategorized"""
+async def delete_uncategorized_images(
+ current_user: CurrentUserOrDefault,
+) -> DeleteImagesResult:
+ """Deletes all uncategorized images owned by the current user (or all if admin)"""
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id="none", categories=None, is_intermediate=None
@@ -445,9 +544,13 @@ async def delete_uncategorized_images() -> DeleteImagesResult:
affected_boards: set[str] = set()
for image_name in image_names:
try:
+ _assert_image_owner(image_name, current_user)
ApiDependencies.invoker.services.images.delete(image_name)
deleted_images.add(image_name)
affected_boards.add("none")
+ except HTTPException:
+ # Skip images not owned by the current user
+ pass
except Exception:
pass
return DeleteImagesResult(
@@ -464,6 +567,7 @@ class ImagesUpdatedFromListResult(BaseModel):
@images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult)
async def star_images_in_list(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The list of names of images to star", embed=True),
) -> StarredImagesResult:
try:
@@ -471,23 +575,29 @@ async def star_images_in_list(
affected_boards: set[str] = set()
for image_name in image_names:
try:
+ _assert_image_owner(image_name, current_user)
updated_image_dto = ApiDependencies.invoker.services.images.update(
image_name, changes=ImageRecordChanges(starred=True)
)
starred_images.add(image_name)
affected_boards.add(updated_image_dto.board_id or "none")
+ except HTTPException:
+ raise
except Exception:
pass
return StarredImagesResult(
starred_images=list(starred_images),
affected_boards=list(affected_boards),
)
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to star images")
@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult)
async def unstar_images_in_list(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The list of names of images to unstar", embed=True),
) -> UnstarredImagesResult:
try:
@@ -495,17 +605,22 @@ async def unstar_images_in_list(
affected_boards: set[str] = set()
for image_name in image_names:
try:
+ _assert_image_owner(image_name, current_user)
updated_image_dto = ApiDependencies.invoker.services.images.update(
image_name, changes=ImageRecordChanges(starred=False)
)
unstarred_images.add(image_name)
affected_boards.add(updated_image_dto.board_id or "none")
+ except HTTPException:
+ raise
except Exception:
pass
return UnstarredImagesResult(
unstarred_images=list(unstarred_images),
affected_boards=list(affected_boards),
)
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to unstar images")
@@ -523,6 +638,7 @@ class ImagesDownloaded(BaseModel):
"/download", operation_id="download_images_from_list", response_model=ImagesDownloaded, status_code=202
)
async def download_images_from_list(
+ current_user: CurrentUserOrDefault,
background_tasks: BackgroundTasks,
image_names: Optional[list[str]] = Body(
default=None, description="The list of names of images to download", embed=True
@@ -558,6 +674,7 @@ async def download_images_from_list(
},
)
async def get_bulk_download_item(
+ current_user: CurrentUserOrDefault,
background_tasks: BackgroundTasks,
bulk_download_item_name: str = Path(description="The bulk_download_item_name of the bulk download item to get"),
) -> FileResponse:
@@ -617,6 +734,7 @@ async def get_image_names(
responses={200: {"model": list[ImageDTO]}},
)
async def get_images_by_names(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(embed=True, description="Object containing list of image names to fetch DTOs for"),
) -> list[ImageDTO]:
"""Gets image DTOs for the specified image names. Maintains order of input names."""
@@ -628,8 +746,12 @@ async def get_images_by_names(
image_dtos: list[ImageDTO] = []
for name in image_names:
try:
+ _assert_image_read_access(name, current_user)
dto = image_service.get_dto(name)
image_dtos.append(dto)
+ except HTTPException:
+ # Skip images the user is not authorized to view
+ continue
except Exception:
# Skip missing images - they may have been deleted between name fetch and DTO fetch
continue
diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py
index 0af3fd29b0c..d0aef30ff8a 100644
--- a/invokeai/app/api/routers/recall_parameters.py
+++ b/invokeai/app/api/routers/recall_parameters.py
@@ -7,6 +7,7 @@
from fastapi.routing import APIRouter
from pydantic import BaseModel, ConfigDict, Field
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.backend.image_util.controlnet_processor import process_controlnet_image
from invokeai.backend.model_manager.taxonomy import ModelType
@@ -297,6 +298,7 @@ def resolve_ip_adapter_models(ip_adapters: list[IPAdapterRecallParameter]) -> li
response_model=dict[str, Any],
)
async def update_recall_parameters(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(..., description="The queue id to perform this operation on"),
parameters: RecallParameter = Body(..., description="Recall parameters to update"),
) -> dict[str, Any]:
@@ -425,6 +427,7 @@ async def update_recall_parameters(
response_model=dict[str, Any],
)
async def get_recall_parameters(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(..., description="The queue id to retrieve parameters for"),
) -> dict[str, Any]:
"""
diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py
index 403e7727cb4..fdb2e1dd569 100644
--- a/invokeai/app/api/routers/session_queue.py
+++ b/invokeai/app/api/routers/session_queue.py
@@ -126,6 +126,7 @@ async def list_all_queue_items(
},
)
async def get_queue_item_ids(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
) -> ItemIdsResult:
@@ -376,11 +377,15 @@ async def prune(
},
)
async def get_current_queue_item(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> Optional[SessionQueueItem]:
"""Gets the currently execution queue item"""
try:
- return ApiDependencies.invoker.services.session_queue.get_current(queue_id)
+ item = ApiDependencies.invoker.services.session_queue.get_current(queue_id)
+ if item is not None:
+ item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin)
+ return item
except Exception as e:
raise HTTPException(status_code=500, detail=f"Unexpected error while getting current queue item: {e}")
@@ -393,11 +398,15 @@ async def get_current_queue_item(
},
)
async def get_next_queue_item(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> Optional[SessionQueueItem]:
"""Gets the next queue item, without executing it"""
try:
- return ApiDependencies.invoker.services.session_queue.get_next(queue_id)
+ item = ApiDependencies.invoker.services.session_queue.get_next(queue_id)
+ if item is not None:
+ item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin)
+ return item
except Exception as e:
raise HTTPException(status_code=500, detail=f"Unexpected error while getting next queue item: {e}")
@@ -430,6 +439,7 @@ async def get_queue_status(
},
)
async def get_batch_status(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
batch_id: str = Path(description="The batch to get the status of"),
) -> BatchStatus:
@@ -529,6 +539,7 @@ async def cancel_queue_item(
responses={200: {"model": SessionQueueCountsByDestination}},
)
async def counts_by_destination(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to query"),
destination: str = Query(description="The destination to query"),
) -> SessionQueueCountsByDestination:
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 7e34660a1df..785083ec5ae 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -259,8 +259,12 @@ async def delete_workflow_thumbnail(
async def get_workflow_thumbnail(
workflow_id: str = Path(description="The id of the workflow thumbnail to get"),
) -> FileResponse:
- """Gets a workflow's thumbnail image"""
+ """Gets a workflow's thumbnail image.
+ This endpoint is intentionally unauthenticated because browsers load images
+ via
tags which cannot send Bearer tokens. Workflow IDs are UUIDs,
+ providing security through unguessability.
+ """
try:
path = ApiDependencies.invoker.services.workflow_thumbnails.get_path(workflow_id)
@@ -368,7 +372,17 @@ async def counts_by_category(
operation_id="update_opened_at",
)
async def update_opened_at(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to update"),
) -> None:
"""Updates the opened_at field of a workflow"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
ApiDependencies.invoker.services.workflow_records.update_opened_at(workflow_id)
diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py
index 16405c52708..9edf4fef7d3 100644
--- a/invokeai/app/services/image_records/image_records_base.py
+++ b/invokeai/app/services/image_records/image_records_base.py
@@ -97,6 +97,11 @@ def save(
"""Saves an image record."""
pass
+ @abstractmethod
+ def get_user_id(self, image_name: str) -> Optional[str]:
+ """Gets the user_id of the image owner. Returns None if image not found."""
+ pass
+
@abstractmethod
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
"""Gets the most recent image for a board."""
diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py
index c6c237fc1e7..e9a67255636 100644
--- a/invokeai/app/services/image_records/image_records_sqlite.py
+++ b/invokeai/app/services/image_records/image_records_sqlite.py
@@ -46,6 +46,20 @@ def get(self, image_name: str) -> ImageRecord:
return deserialize_image_record(dict(result))
+ def get_user_id(self, image_name: str) -> Optional[str]:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT user_id FROM images
+ WHERE image_name = ?;
+ """,
+ (image_name,),
+ )
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+ if not result:
+ return None
+ return cast(Optional[str], dict(result).get("user_id"))
+
def get_metadata(self, image_name: str) -> Optional[MetadataField]:
with self._db.transaction() as cursor:
try:
diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py
index c0da3ec51ca..619ecb78c4f 100644
--- a/tests/app/routers/test_images.py
+++ b/tests/app/routers/test_images.py
@@ -52,7 +52,9 @@ def mock_get(*args, **kwargs):
def prepare_download_images_test(monkeypatch: Any, mock_invoker: Invoker) -> None:
- monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", MockApiDependencies(mock_invoker))
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
monkeypatch.setattr(
"invokeai.app.api.routers.images.ApiDependencies.invoker.services.bulk_download.generate_item_id",
lambda arg: "test",
@@ -79,7 +81,9 @@ def test_get_bulk_download_image(tmp_path: Path, monkeypatch: Any, mock_invoker:
mock_file.write_text("contents")
monkeypatch.setattr(mock_invoker.services.bulk_download, "get_path", lambda x: str(mock_file))
- monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", MockApiDependencies(mock_invoker))
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
def mock_add_task(*args, **kwargs):
return None
@@ -93,7 +97,9 @@ def mock_add_task(*args, **kwargs):
def test_get_bulk_download_image_not_found(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
- monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", MockApiDependencies(mock_invoker))
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
def mock_add_task(*args, **kwargs):
return None
@@ -112,7 +118,9 @@ def test_get_bulk_download_image_image_deleted_after_response(
mock_file.write_text("contents")
monkeypatch.setattr(mock_invoker.services.bulk_download, "get_path", lambda x: str(mock_file))
- monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", MockApiDependencies(mock_invoker))
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
client.get("/api/v1/images/download/test.zip")
diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py
new file mode 100644
index 00000000000..42f0d4fc86d
--- /dev/null
+++ b/tests/app/routers/test_multiuser_authorization.py
@@ -0,0 +1,725 @@
+"""Tests for API-level authorization on board-image mutations, image mutations,
+workflow thumbnail access, and admin email leak prevention.
+
+These tests verify the security fixes for:
+1. Shared-board write protection bypass via direct API calls
+2. Image mutation endpoints lacking ownership checks
+3. Private workflow thumbnail exposure
+4. Admin email leak on unauthenticated status endpoint
+"""
+
+import logging
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api_app import app
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.invocation_services import InvocationServices
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.users.users_common import UserCreateRequest
+from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
+from invokeai.backend.util.logging import InvokeAILogger
+from tests.fixtures.sqlite_database import create_mock_sqlite_database
+
+
+class MockApiDependencies(ApiDependencies):
+ invoker: Invoker
+
+ def __init__(self, invoker: Invoker) -> None:
+ self.invoker = invoker
+
+
+WORKFLOW_BODY = {
+ "name": "Test Workflow",
+ "author": "",
+ "description": "",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "",
+ "notes": "",
+ "nodes": [],
+ "edges": [],
+ "exposedFields": [],
+ "meta": {"version": "3.0.0", "category": "user"},
+ "id": None,
+ "form_fields": [],
+}
+
+
+@pytest.fixture
+def setup_jwt_secret():
+ from invokeai.app.services.auth.token_service import set_jwt_secret
+
+ set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+@pytest.fixture
+def mock_services() -> InvocationServices:
+ from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
+ from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
+ from invokeai.app.services.boards.boards_default import BoardService
+ from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
+ from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import (
+ ClientStatePersistenceSqlite,
+ )
+ from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
+ from invokeai.app.services.images.images_default import ImageService
+ from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
+ from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
+ from invokeai.app.services.users.users_default import UserService
+ from tests.test_nodes import TestEventService
+
+ configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
+ logger = InvokeAILogger.get_logger()
+ db = create_mock_sqlite_database(configuration, logger)
+
+ return InvocationServices(
+ board_image_records=SqliteBoardImageRecordStorage(db=db),
+ board_images=None, # type: ignore
+ board_records=SqliteBoardRecordStorage(db=db),
+ boards=BoardService(),
+ bulk_download=BulkDownloadService(),
+ configuration=configuration,
+ events=TestEventService(),
+ image_files=None, # type: ignore
+ image_records=SqliteImageRecordStorage(db=db),
+ images=ImageService(),
+ invocation_cache=MemoryInvocationCache(max_cache_size=0),
+ logger=logging, # type: ignore
+ model_images=None, # type: ignore
+ model_manager=None, # type: ignore
+ download_queue=None, # type: ignore
+ names=None, # type: ignore
+ performance_statistics=InvocationStatsService(),
+ session_processor=None, # type: ignore
+ session_queue=None, # type: ignore
+ urls=None, # type: ignore
+ workflow_records=SqliteWorkflowRecordsStorage(db=db),
+ tensors=None, # type: ignore
+ conditioning=None, # type: ignore
+ style_preset_records=None, # type: ignore
+ style_preset_image_files=None, # type: ignore
+ workflow_thumbnails=None, # type: ignore
+ model_relationship_records=None, # type: ignore
+ model_relationships=None, # type: ignore
+ client_state_persistence=ClientStatePersistenceSqlite(db=db),
+ users=UserService(db),
+ )
+
+
+@pytest.fixture()
+def mock_invoker(mock_services: InvocationServices) -> Invoker:
+ return Invoker(services=mock_services)
+
+
+def _save_image(mock_invoker: Invoker, image_name: str, user_id: str) -> None:
+ """Helper to insert an image record owned by a specific user."""
+ from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+
+ mock_invoker.services.image_records.save(
+ image_name=image_name,
+ image_origin=ResourceOrigin.INTERNAL,
+ image_category=ImageCategory.GENERAL,
+ width=100,
+ height=100,
+ has_workflow=False,
+ user_id=user_id,
+ )
+
+
+def _create_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
+ user = mock_invoker.services.users.create(
+ UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
+ )
+ return user.user_id
+
+
+def _login(client: TestClient, email: str) -> str:
+ r = client.post("/api/v1/auth/login", json={"email": email, "password": "TestPass123", "remember_me": False})
+ assert r.status_code == 200
+ return r.json()["token"]
+
+
+def _auth(token: str) -> dict[str, str]:
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
+ mock_invoker.services.configuration.multiuser = True
+
+ mock_board_images = MagicMock()
+ mock_board_images.get_all_board_image_names_for_board.return_value = []
+ mock_invoker.services.board_images = mock_board_images
+
+ mock_workflow_thumbnails = MagicMock()
+ mock_workflow_thumbnails.get_url.return_value = None
+ mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
+
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.session_queue.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.recall_parameters.ApiDependencies", mock_deps)
+ yield
+
+
+@pytest.fixture
+def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient):
+ _create_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
+ return _login(client, "admin@test.com")
+
+
+@pytest.fixture
+def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
+ _create_user(mock_invoker, "user1@test.com", "User One")
+ return _login(client, "user1@test.com")
+
+
+@pytest.fixture
+def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
+ _create_user(mock_invoker, "user2@test.com", "User Two")
+ return _login(client, "user2@test.com")
+
+
+def _create_board(client: TestClient, token: str, name: str = "Test Board") -> str:
+ r = client.post(f"/api/v1/boards/?board_name={name.replace(' ', '+')}", headers=_auth(token))
+ assert r.status_code == status.HTTP_201_CREATED
+ return r.json()["board_id"]
+
+
+def _share_board(client: TestClient, token: str, board_id: str) -> None:
+ r = client.patch(f"/api/v1/boards/{board_id}", json={"board_visibility": "shared"}, headers=_auth(token))
+ assert r.status_code == status.HTTP_201_CREATED
+
+
+def _create_workflow(client: TestClient, token: str) -> str:
+ r = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(token))
+ assert r.status_code == 200
+ return r.json()["workflow_id"]
+
+
+# ===========================================================================
+# 1. Board-image mutation authorization
+# ===========================================================================
+
+
+class TestBoardImageMutationAuth:
+ """Tests that board_images mutation endpoints enforce ownership."""
+
+ def test_add_image_to_board_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/board_images/", json={"board_id": "x", "image_name": "y"})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_add_image_to_board_batch_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/board_images/batch", json={"board_id": "x", "image_names": ["y"]})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_remove_image_from_board_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.request("DELETE", "/api/v1/board_images/", json={"image_name": "y"})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_remove_images_from_board_batch_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/board_images/batch/delete", json={"image_names": ["y"]})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_non_owner_cannot_add_image_to_shared_board(
+ self, client: TestClient, user1_token: str, user2_token: str
+ ):
+ board_id = _create_board(client, user1_token, "User1 Shared Board")
+ _share_board(client, user1_token, board_id)
+
+ r = client.post(
+ "/api/v1/board_images/",
+ json={"board_id": board_id, "image_name": "some-image"},
+ headers=_auth(user2_token),
+ )
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_non_owner_cannot_add_images_batch_to_shared_board(
+ self, client: TestClient, user1_token: str, user2_token: str
+ ):
+ board_id = _create_board(client, user1_token, "User1 Shared Board Batch")
+ _share_board(client, user1_token, board_id)
+
+ r = client.post(
+ "/api/v1/board_images/batch",
+ json={"board_id": board_id, "image_names": ["img1", "img2"]},
+ headers=_auth(user2_token),
+ )
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_admin_can_add_image_to_any_board(
+ self, client: TestClient, admin_token: str, user1_token: str
+ ):
+ board_id = _create_board(client, user1_token, "User1 Board For Admin")
+
+ # This may 500 because the image doesn't exist in the DB, but it should NOT be 403
+ r = client.post(
+ "/api/v1/board_images/",
+ json={"board_id": board_id, "image_name": "some-image"},
+ headers=_auth(admin_token),
+ )
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+ def test_owner_can_add_image_to_own_board(self, client: TestClient, user1_token: str):
+ board_id = _create_board(client, user1_token, "User1 Own Board")
+
+ # May 500 (no real image) but should not be 403
+ r = client.post(
+ "/api/v1/board_images/",
+ json={"board_id": board_id, "image_name": "some-image"},
+ headers=_auth(user1_token),
+ )
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+
+# ===========================================================================
+# 2a. Image read-access authorization
+# ===========================================================================
+
+
+class TestImageReadAuth:
+ """Tests that image GET endpoints enforce visibility."""
+
+ def test_get_image_dto_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/images/i/some-image")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_get_image_metadata_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/images/i/some-image/metadata")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_get_image_full_is_unauthenticated(self, enable_multiuser: Any, client: TestClient):
+ # Binary image endpoints are intentionally unauthenticated because
+ # browsers load them via
which cannot send Bearer tokens.
+ r = client.get("/api/v1/images/i/some-image/full")
+ assert r.status_code != status.HTTP_401_UNAUTHORIZED
+
+ def test_get_image_thumbnail_is_unauthenticated(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/images/i/some-image/thumbnail")
+ assert r.status_code != status.HTTP_401_UNAUTHORIZED
+
+ def test_get_image_urls_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/images/i/some-image/urls")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_non_owner_cannot_read_private_image(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ """User2 should not be able to read user1's image that is not on a shared board."""
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-private-img", user1.user_id)
+
+ r = client.get("/api/v1/images/i/user1-private-img", headers=_auth(user2_token))
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_owner_can_read_own_image(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str
+ ):
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-readable", user1.user_id)
+
+ r = client.get("/api/v1/images/i/user1-readable", headers=_auth(user1_token))
+ # Should not be 403 (may be 404/500 due to missing board_image_records mock)
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+ def test_admin_can_read_any_image(
+ self, client: TestClient, mock_invoker: Invoker, admin_token: str, user1_token: str
+ ):
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-admin-read", user1.user_id)
+
+ r = client.get("/api/v1/images/i/user1-admin-read", headers=_auth(admin_token))
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+ def test_shared_board_image_readable_by_other_user(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ """An image on a shared board should be readable by any authenticated user."""
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "shared-board-img", user1.user_id)
+
+ # Create a shared board and add the image to it
+ board_id = _create_board(client, user1_token, "Shared Read Board")
+ _share_board(client, user1_token, board_id)
+ mock_invoker.services.board_image_records.add_image_to_board(
+ board_id=board_id, image_name="shared-board-img"
+ )
+
+ r = client.get("/api/v1/images/i/shared-board-img", headers=_auth(user2_token))
+ # Should not be 403 — image is on a shared board
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+ def test_non_owner_cannot_read_image_metadata(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-meta-blocked", user1.user_id)
+
+ r = client.get("/api/v1/images/i/user1-meta-blocked/metadata", headers=_auth(user2_token))
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+
+# ===========================================================================
+# 2b. Image mutation authorization
+# ===========================================================================
+
+
+class TestImageUploadAuth:
+ """Tests that image upload enforces board ownership."""
+
+ def test_upload_to_other_users_shared_board_forbidden(
+ self, client: TestClient, user1_token: str, user2_token: str
+ ):
+ """A user should not be able to upload an image into another user's shared board."""
+ board_id = _create_board(client, user1_token, "User1 Shared Upload Board")
+ _share_board(client, user1_token, board_id)
+
+ # user2 tries to upload into user1's shared board
+ import io
+
+ fake_image = io.BytesIO(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
+ r = client.post(
+ f"/api/v1/images/upload?image_category=general&is_intermediate=false&board_id={board_id}",
+ files={"file": ("test.png", fake_image, "image/png")},
+ headers=_auth(user2_token),
+ )
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_owner_can_upload_to_own_shared_board(
+ self, client: TestClient, user1_token: str
+ ):
+ board_id = _create_board(client, user1_token, "User1 Own Upload Board")
+ _share_board(client, user1_token, board_id)
+
+ import io
+
+ fake_image = io.BytesIO(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
+ r = client.post(
+ f"/api/v1/images/upload?image_category=general&is_intermediate=false&board_id={board_id}",
+ files={"file": ("test.png", fake_image, "image/png")},
+ headers=_auth(user1_token),
+ )
+ # Should not be 403 (may fail for other reasons in test env)
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+
+class TestImageMutationAuth:
+ """Tests that image mutation endpoints enforce ownership."""
+
+ def test_delete_image_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.delete("/api/v1/images/i/some-image")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_update_image_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.patch("/api/v1/images/i/some-image", json={"starred": True})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_batch_delete_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/images/delete", json={"image_names": ["x"]})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_star_images_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/images/star", json={"image_names": ["x"]})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_unstar_images_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/images/unstar", json={"image_names": ["x"]})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_clear_intermediates_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.delete("/api/v1/images/intermediates")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_delete_uncategorized_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.delete("/api/v1/images/uncategorized")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_non_owner_cannot_delete_image(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ """User2 should not be able to delete user1's image."""
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-image", user1.user_id)
+
+ r = client.delete("/api/v1/images/i/user1-image", headers=_auth(user2_token))
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_owner_can_delete_own_image(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str
+ ):
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-delete-me", user1.user_id)
+
+ r = client.delete("/api/v1/images/i/user1-delete-me", headers=_auth(user1_token))
+ # Should not be 403 (may be 200 or 500 depending on file system)
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+ def test_admin_can_delete_any_image(
+ self, client: TestClient, mock_invoker: Invoker, admin_token: str, user1_token: str
+ ):
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-admin-delete", user1.user_id)
+
+ r = client.delete("/api/v1/images/i/user1-admin-delete", headers=_auth(admin_token))
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+ def test_board_owner_can_delete_image_on_own_board(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str
+ ):
+ """Board owner should be able to delete images on their board even if
+ the image's user_id is 'system' (e.g. generated images)."""
+ # Create image owned by "system" (simulates queue-generated image)
+ _save_image(mock_invoker, "system-img-on-board", "system")
+
+ # Create a board owned by user1 and add the image to it
+ board_id = _create_board(client, user1_token, "User1 Board With System Img")
+ mock_invoker.services.board_image_records.add_image_to_board(
+ board_id=board_id, image_name="system-img-on-board"
+ )
+
+ r = client.delete("/api/v1/images/i/system-img-on-board", headers=_auth(user1_token))
+ assert r.status_code != status.HTTP_403_FORBIDDEN
+
+ def test_non_owner_cannot_update_image(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-no-star", user1.user_id)
+
+ r = client.patch(
+ "/api/v1/images/i/user1-no-star",
+ json={"starred": True},
+ headers=_auth(user2_token),
+ )
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_non_owner_cannot_star_image(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-star-blocked", user1.user_id)
+
+ r = client.post(
+ "/api/v1/images/star",
+ json={"image_names": ["user1-star-blocked"]},
+ headers=_auth(user2_token),
+ )
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_non_owner_cannot_batch_delete_image(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-batch-del", user1.user_id)
+
+ r = client.post(
+ "/api/v1/images/delete",
+ json={"image_names": ["user1-batch-del"]},
+ headers=_auth(user2_token),
+ )
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_clear_intermediates_non_admin_forbidden(self, client: TestClient, user1_token: str):
+ r = client.delete("/api/v1/images/intermediates", headers=_auth(user1_token))
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_get_intermediates_count_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/images/intermediates")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_download_images_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/images/download", json={"image_names": ["x"]})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_get_bulk_download_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/images/download/some-item.zip")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_images_by_names_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/images/images_by_names", json={"image_names": ["x"]})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_images_by_names_filters_unauthorized(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ """images_by_names should silently skip images the caller cannot access."""
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ assert user1 is not None
+ _save_image(mock_invoker, "user1-by-name", user1.user_id)
+
+ r = client.post(
+ "/api/v1/images/images_by_names",
+ json={"image_names": ["user1-by-name"]},
+ headers=_auth(user2_token),
+ )
+ assert r.status_code == 200
+ # user2 should get an empty list — the image belongs to user1
+ assert r.json() == []
+
+
+# ===========================================================================
+# 3. Workflow mutation authorization (additional)
+# ===========================================================================
+
+
+class TestWorkflowMutationAuth:
+ """Tests for additional workflow mutation endpoints."""
+
+ def test_update_opened_at_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.put("/api/v1/workflows/i/some-id/opened_at")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_non_owner_cannot_update_opened_at(
+ self, client: TestClient, user1_token: str, user2_token: str
+ ):
+ workflow_id = _create_workflow(client, user1_token)
+ r = client.put(
+ f"/api/v1/workflows/i/{workflow_id}/opened_at",
+ headers=_auth(user2_token),
+ )
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_owner_can_update_opened_at(self, client: TestClient, user1_token: str):
+ workflow_id = _create_workflow(client, user1_token)
+ r = client.put(
+ f"/api/v1/workflows/i/{workflow_id}/opened_at",
+ headers=_auth(user1_token),
+ )
+ assert r.status_code == 200
+
+
+# ===========================================================================
+# 4. Workflow thumbnail authorization
+# ===========================================================================
+
+
+class TestWorkflowThumbnailAuth:
+ """Tests for the workflow thumbnail GET endpoint.
+
+ Workflow and image thumbnail endpoints are intentionally unauthenticated
+ because browsers load them via
tags which cannot send Bearer
+ tokens. IDs are UUIDs, providing security through unguessability.
+ """
+
+ def test_thumbnail_is_unauthenticated(self, enable_multiuser: Any, client: TestClient):
+ # Binary image endpoints don't require auth — loaded via
+ r = client.get("/api/v1/workflows/i/some-workflow/thumbnail")
+ assert r.status_code != status.HTTP_401_UNAUTHORIZED
+
+
+# ===========================================================================
+# 4. Admin email leak prevention
+# ===========================================================================
+
+
+class TestAdminEmailLeak:
+ """Tests that the auth status endpoint does not leak admin email."""
+
+ def test_status_does_not_leak_admin_email_when_setup_complete(
+ self, client: TestClient, admin_token: str
+ ):
+ """After setup is complete, admin_email must be null."""
+ r = client.get("/api/v1/auth/status")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["multiuser_enabled"] is True
+ assert data["setup_required"] is False
+ assert data["admin_email"] is None
+
+ def test_status_returns_admin_email_during_setup(
+ self, setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient
+ ):
+ """Before any admin exists, setup_required=True and admin_email may be returned."""
+ # Don't create any users -- setup_required should be True
+ r = client.get("/api/v1/auth/status")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["setup_required"] is True
+ # admin_email is null here because no admin exists yet, which is correct
+
+ def test_status_no_leak_in_single_user_mode(
+ self, setup_jwt_secret: None, monkeypatch: Any, mock_invoker: Invoker, client: TestClient
+ ):
+ """In single-user mode, admin_email should always be null."""
+ mock_invoker.services.configuration.multiuser = False
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
+
+ r = client.get("/api/v1/auth/status")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["admin_email"] is None
+ assert data["multiuser_enabled"] is False
+
+
+# ===========================================================================
+# 6. Session queue authorization
+# ===========================================================================
+
+
+class TestSessionQueueAuth:
+ """Tests that session queue endpoints enforce authentication."""
+
+ def test_get_queue_item_ids_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/queue/default/item_ids")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_get_current_queue_item_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/queue/default/current")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_get_next_queue_item_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/queue/default/next")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_get_batch_status_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/queue/default/b/some-batch/status")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_counts_by_destination_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/queue/default/counts_by_destination?destination=canvas")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+# ===========================================================================
+# 7. Recall parameters authorization
+# ===========================================================================
+
+
+class TestRecallParametersAuth:
+ """Tests that recall parameter endpoints enforce authentication."""
+
+ def test_get_recall_parameters_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.get("/api/v1/recall/default")
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_update_recall_parameters_requires_auth(self, enable_multiuser: Any, client: TestClient):
+ r = client.post("/api/v1/recall/default", json={"positive_prompt": "test"})
+ assert r.status_code == status.HTTP_401_UNAUTHORIZED
From ac1f1a546619d16c6e58decc0d5a2ccdbe0851af Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Mon, 6 Apr 2026 22:37:44 -0400
Subject: [PATCH 011/100] chore(backend): ruff
---
.../routers/test_multiuser_authorization.py | 38 +++++--------------
1 file changed, 10 insertions(+), 28 deletions(-)
diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py
index 42f0d4fc86d..da2677d1275 100644
--- a/tests/app/routers/test_multiuser_authorization.py
+++ b/tests/app/routers/test_multiuser_authorization.py
@@ -10,7 +10,7 @@
import logging
from typing import Any
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock
import pytest
from fastapi import status
@@ -237,9 +237,7 @@ def test_remove_images_from_board_batch_requires_auth(self, enable_multiuser: An
r = client.post("/api/v1/board_images/batch/delete", json={"image_names": ["y"]})
assert r.status_code == status.HTTP_401_UNAUTHORIZED
- def test_non_owner_cannot_add_image_to_shared_board(
- self, client: TestClient, user1_token: str, user2_token: str
- ):
+ def test_non_owner_cannot_add_image_to_shared_board(self, client: TestClient, user1_token: str, user2_token: str):
board_id = _create_board(client, user1_token, "User1 Shared Board")
_share_board(client, user1_token, board_id)
@@ -263,9 +261,7 @@ def test_non_owner_cannot_add_images_batch_to_shared_board(
)
assert r.status_code == status.HTTP_403_FORBIDDEN
- def test_admin_can_add_image_to_any_board(
- self, client: TestClient, admin_token: str, user1_token: str
- ):
+ def test_admin_can_add_image_to_any_board(self, client: TestClient, admin_token: str, user1_token: str):
board_id = _create_board(client, user1_token, "User1 Board For Admin")
# This may 500 because the image doesn't exist in the DB, but it should NOT be 403
@@ -329,9 +325,7 @@ def test_non_owner_cannot_read_private_image(
r = client.get("/api/v1/images/i/user1-private-img", headers=_auth(user2_token))
assert r.status_code == status.HTTP_403_FORBIDDEN
- def test_owner_can_read_own_image(
- self, client: TestClient, mock_invoker: Invoker, user1_token: str
- ):
+ def test_owner_can_read_own_image(self, client: TestClient, mock_invoker: Invoker, user1_token: str):
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
_save_image(mock_invoker, "user1-readable", user1.user_id)
@@ -361,9 +355,7 @@ def test_shared_board_image_readable_by_other_user(
# Create a shared board and add the image to it
board_id = _create_board(client, user1_token, "Shared Read Board")
_share_board(client, user1_token, board_id)
- mock_invoker.services.board_image_records.add_image_to_board(
- board_id=board_id, image_name="shared-board-img"
- )
+ mock_invoker.services.board_image_records.add_image_to_board(board_id=board_id, image_name="shared-board-img")
r = client.get("/api/v1/images/i/shared-board-img", headers=_auth(user2_token))
# Should not be 403 — image is on a shared board
@@ -388,9 +380,7 @@ def test_non_owner_cannot_read_image_metadata(
class TestImageUploadAuth:
"""Tests that image upload enforces board ownership."""
- def test_upload_to_other_users_shared_board_forbidden(
- self, client: TestClient, user1_token: str, user2_token: str
- ):
+ def test_upload_to_other_users_shared_board_forbidden(self, client: TestClient, user1_token: str, user2_token: str):
"""A user should not be able to upload an image into another user's shared board."""
board_id = _create_board(client, user1_token, "User1 Shared Upload Board")
_share_board(client, user1_token, board_id)
@@ -406,9 +396,7 @@ def test_upload_to_other_users_shared_board_forbidden(
)
assert r.status_code == status.HTTP_403_FORBIDDEN
- def test_owner_can_upload_to_own_shared_board(
- self, client: TestClient, user1_token: str
- ):
+ def test_owner_can_upload_to_own_shared_board(self, client: TestClient, user1_token: str):
board_id = _create_board(client, user1_token, "User1 Own Upload Board")
_share_board(client, user1_token, board_id)
@@ -466,9 +454,7 @@ def test_non_owner_cannot_delete_image(
r = client.delete("/api/v1/images/i/user1-image", headers=_auth(user2_token))
assert r.status_code == status.HTTP_403_FORBIDDEN
- def test_owner_can_delete_own_image(
- self, client: TestClient, mock_invoker: Invoker, user1_token: str
- ):
+ def test_owner_can_delete_own_image(self, client: TestClient, mock_invoker: Invoker, user1_token: str):
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
_save_image(mock_invoker, "user1-delete-me", user1.user_id)
@@ -596,9 +582,7 @@ def test_update_opened_at_requires_auth(self, enable_multiuser: Any, client: Tes
r = client.put("/api/v1/workflows/i/some-id/opened_at")
assert r.status_code == status.HTTP_401_UNAUTHORIZED
- def test_non_owner_cannot_update_opened_at(
- self, client: TestClient, user1_token: str, user2_token: str
- ):
+ def test_non_owner_cannot_update_opened_at(self, client: TestClient, user1_token: str, user2_token: str):
workflow_id = _create_workflow(client, user1_token)
r = client.put(
f"/api/v1/workflows/i/{workflow_id}/opened_at",
@@ -642,9 +626,7 @@ def test_thumbnail_is_unauthenticated(self, enable_multiuser: Any, client: TestC
class TestAdminEmailLeak:
"""Tests that the auth status endpoint does not leak admin email."""
- def test_status_does_not_leak_admin_email_when_setup_complete(
- self, client: TestClient, admin_token: str
- ):
+ def test_status_does_not_leak_admin_email_when_setup_complete(self, client: TestClient, admin_token: str):
"""After setup is complete, admin_email must be null."""
r = client.get("/api/v1/auth/status")
assert r.status_code == 200
From c2e7a5dec885a3715d66b69efd01b3c49f5d6bda Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 13:38:50 -0500
Subject: [PATCH 012/100] Add workflow live update events
Stacked on top of origin PR #9018 (shared/private workflows and boards) for multiuser workflow visibility semantics.
---
invokeai/app/api/routers/workflows.py | 49 +++++--
invokeai/app/api/sockets.py | 35 +++++
invokeai/app/services/events/events_base.py | 26 ++++
invokeai/app/services/events/events_common.py | 54 ++++++++
.../services/events/setEventListeners.test.ts | 99 ++++++++++++++
.../src/services/events/setEventListeners.tsx | 26 ++++
.../frontend/web/src/services/events/types.ts | 12 ++
.../app/routers/test_workflow_live_updates.py | 118 ++++++++++++++++
tests/app/test_workflow_socketio.py | 126 ++++++++++++++++++
9 files changed, 533 insertions(+), 12 deletions(-)
create mode 100644 invokeai/frontend/web/src/services/events/setEventListeners.test.ts
create mode 100644 tests/app/routers/test_workflow_live_updates.py
create mode 100644 tests/app/test_workflow_socketio.py
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 785083ec5ae..7410ea23eaf 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -66,15 +66,23 @@ async def update_workflow(
workflow: Workflow = Body(description="The updated workflow", embed=True),
) -> WorkflowRecordDTO:
"""Updates a workflow"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow.id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
config = ApiDependencies.invoker.services.configuration
if config.multiuser:
- try:
- existing = ApiDependencies.invoker.services.workflow_records.get(workflow.id)
- except WorkflowNotFoundError:
- raise HTTPException(status_code=404, detail="Workflow not found")
if not current_user.is_admin and existing.user_id != current_user.user_id:
raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
- return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow)
+ updated = ApiDependencies.invoker.services.workflow_records.update(workflow=workflow)
+ ApiDependencies.invoker.services.events.emit_workflow_updated(
+ workflow_id=updated.workflow_id,
+ user_id=updated.user_id,
+ old_is_public=existing.is_public,
+ new_is_public=updated.is_public,
+ )
+ return updated
@workflows_router.delete(
@@ -86,12 +94,13 @@ async def delete_workflow(
workflow_id: str = Path(description="The workflow to delete"),
) -> None:
"""Deletes a workflow"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
config = ApiDependencies.invoker.services.configuration
if config.multiuser:
- try:
- existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
- except WorkflowNotFoundError:
- raise HTTPException(status_code=404, detail="Workflow not found")
if not current_user.is_admin and existing.user_id != current_user.user_id:
raise HTTPException(status_code=403, detail="Not authorized to delete this workflow")
try:
@@ -100,6 +109,11 @@ async def delete_workflow(
# It's OK if the workflow has no thumbnail file. We can still delete the workflow.
pass
ApiDependencies.invoker.services.workflow_records.delete(workflow_id)
+ ApiDependencies.invoker.services.events.emit_workflow_deleted(
+ workflow_id=existing.workflow_id,
+ user_id=existing.user_id,
+ is_public=existing.is_public,
+ )
@workflows_router.post(
@@ -114,7 +128,13 @@ async def create_workflow(
workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True),
) -> WorkflowRecordDTO:
"""Creates a workflow"""
- return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow, user_id=current_user.user_id)
+ created = ApiDependencies.invoker.services.workflow_records.create(workflow=workflow, user_id=current_user.user_id)
+ ApiDependencies.invoker.services.events.emit_workflow_created(
+ workflow_id=created.workflow_id,
+ user_id=created.user_id,
+ is_public=created.is_public,
+ )
+ return created
@workflows_router.get(
@@ -302,9 +322,14 @@ async def update_workflow_is_public(
if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
- return ApiDependencies.invoker.services.workflow_records.update_is_public(
- workflow_id=workflow_id, is_public=is_public
+ updated = ApiDependencies.invoker.services.workflow_records.update_is_public(workflow_id=workflow_id, is_public=is_public)
+ ApiDependencies.invoker.services.events.emit_workflow_updated(
+ workflow_id=updated.workflow_id,
+ user_id=updated.user_id,
+ old_is_public=existing.is_public,
+ new_is_public=updated.is_public,
)
+ return updated
@workflows_router.get("/tags", operation_id="get_all_tags")
diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py
index fcead54eb1e..396be78199e 100644
--- a/invokeai/app/api/sockets.py
+++ b/invokeai/app/api/sockets.py
@@ -37,6 +37,10 @@
QueueEventBase,
QueueItemStatusChangedEvent,
RecallParametersUpdatedEvent,
+ WorkflowCreatedEvent,
+ WorkflowDeletedEvent,
+ WorkflowEventBase,
+ WorkflowUpdatedEvent,
register_events,
)
from invokeai.backend.util.logging import InvokeAILogger
@@ -86,6 +90,7 @@ class BulkDownloadSubscriptionEvent(BaseModel):
}
BULK_DOWNLOAD_EVENTS = {BulkDownloadStartedEvent, BulkDownloadCompleteEvent, BulkDownloadErrorEvent}
+WORKFLOW_EVENTS = {WorkflowCreatedEvent, WorkflowUpdatedEvent, WorkflowDeletedEvent}
class SocketIO:
@@ -115,6 +120,7 @@ def __init__(self, app: FastAPI):
register_events(QUEUE_EVENTS, self._handle_queue_event)
register_events(MODEL_EVENTS, self._handle_model_event)
register_events(BULK_DOWNLOAD_EVENTS, self._handle_bulk_image_download_event)
+ register_events(WORKFLOW_EVENTS, self._handle_workflow_event)
async def _handle_connect(self, sid: str, environ: dict, auth: dict | None) -> bool:
"""Handle socket connection and authenticate the user.
@@ -145,6 +151,10 @@ async def _handle_connect(self, sid: str, environ: dict, auth: dict | None) -> b
logger.info(
f"Socket {sid} connected with user_id: {token_data.user_id}, is_admin: {token_data.is_admin}"
)
+ await self._sio.enter_room(sid, f"user:{token_data.user_id}")
+ await self._sio.enter_room(sid, "workflows:shared")
+ if token_data.is_admin:
+ await self._sio.enter_room(sid, "admin")
return True
# If no valid token, store system user for backward compatibility
@@ -266,3 +276,28 @@ async def _handle_model_event(self, event: FastAPIEvent[ModelEventBase | Downloa
async def _handle_bulk_image_download_event(self, event: FastAPIEvent[BulkDownloadEventBase]) -> None:
await self._sio.emit(event=event[0], data=event[1].model_dump(mode="json"), room=event[1].bulk_download_id)
+
+ async def _handle_workflow_event(self, event: FastAPIEvent[WorkflowEventBase]) -> None:
+ event_name, event_data = event
+ payload = event_data.model_dump(mode="json")
+
+ await self._sio.emit(event=event_name, data=payload, room=f"user:{event_data.user_id}")
+ await self._sio.emit(event=event_name, data=payload, room="admin")
+
+ if event_name == "workflow_created":
+ if getattr(event_data, "is_public", False):
+ await self._sio.emit(event=event_name, data=payload, room="workflows:shared")
+ return
+
+ if event_name == "workflow_deleted":
+ if getattr(event_data, "is_public", False):
+ await self._sio.emit(event=event_name, data=payload, room="workflows:shared")
+ return
+
+ if event_name == "workflow_updated":
+ if getattr(event_data, "new_is_public", False):
+ await self._sio.emit(event=event_name, data=payload, room="workflows:shared")
+ elif getattr(event_data, "old_is_public", False):
+ await self._sio.emit(
+ event="workflow_deleted", data={"workflow_id": event_data.workflow_id}, room="workflows:shared"
+ )
diff --git a/invokeai/app/services/events/events_base.py b/invokeai/app/services/events/events_base.py
index aa1cbb5e0ee..17874261d6b 100644
--- a/invokeai/app/services/events/events_base.py
+++ b/invokeai/app/services/events/events_base.py
@@ -32,6 +32,9 @@
QueueItemsRetriedEvent,
QueueItemStatusChangedEvent,
RecallParametersUpdatedEvent,
+ WorkflowCreatedEvent,
+ WorkflowDeletedEvent,
+ WorkflowUpdatedEvent,
)
if TYPE_CHECKING:
@@ -118,6 +121,29 @@ def emit_recall_parameters_updated(self, queue_id: str, parameters: dict) -> Non
# endregion
+ # region Workflow library
+
+ def emit_workflow_created(self, workflow_id: str, user_id: str, is_public: bool) -> None:
+ """Emitted when a workflow is created."""
+ self.dispatch(WorkflowCreatedEvent.build(workflow_id=workflow_id, user_id=user_id, is_public=is_public))
+
+ def emit_workflow_updated(self, workflow_id: str, user_id: str, old_is_public: bool, new_is_public: bool) -> None:
+ """Emitted when a workflow is updated."""
+ self.dispatch(
+ WorkflowUpdatedEvent.build(
+ workflow_id=workflow_id,
+ user_id=user_id,
+ old_is_public=old_is_public,
+ new_is_public=new_is_public,
+ )
+ )
+
+ def emit_workflow_deleted(self, workflow_id: str, user_id: str, is_public: bool) -> None:
+ """Emitted when a workflow is deleted."""
+ self.dispatch(WorkflowDeletedEvent.build(workflow_id=workflow_id, user_id=user_id, is_public=is_public))
+
+ # endregion
+
# region Download
def emit_download_started(self, job: "DownloadJob") -> None:
diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py
index bfb44eb48e8..c9280899824 100644
--- a/invokeai/app/services/events/events_common.py
+++ b/invokeai/app/services/events/events_common.py
@@ -321,6 +321,60 @@ def build(cls, queue_id: str) -> "QueueClearedEvent":
return cls(queue_id=queue_id)
+class WorkflowEventBase(EventBase):
+ """Base class for workflow library CRUD events."""
+
+ workflow_id: str = Field(description="The ID of the workflow")
+ user_id: str = Field(description="The owner of the workflow")
+
+
+@payload_schema.register
+class WorkflowCreatedEvent(WorkflowEventBase):
+ """Event model for workflow_created"""
+
+ __event_name__ = "workflow_created"
+
+ is_public: bool = Field(description="Whether the workflow is shared with all users")
+
+ @classmethod
+ def build(cls, workflow_id: str, user_id: str, is_public: bool) -> "WorkflowCreatedEvent":
+ return cls(workflow_id=workflow_id, user_id=user_id, is_public=is_public)
+
+
+@payload_schema.register
+class WorkflowUpdatedEvent(WorkflowEventBase):
+ """Event model for workflow_updated"""
+
+ __event_name__ = "workflow_updated"
+
+ old_is_public: bool = Field(description="Whether the workflow was shared before the update")
+ new_is_public: bool = Field(description="Whether the workflow is shared after the update")
+
+ @classmethod
+ def build(
+ cls, workflow_id: str, user_id: str, old_is_public: bool, new_is_public: bool
+ ) -> "WorkflowUpdatedEvent":
+ return cls(
+ workflow_id=workflow_id,
+ user_id=user_id,
+ old_is_public=old_is_public,
+ new_is_public=new_is_public,
+ )
+
+
+@payload_schema.register
+class WorkflowDeletedEvent(WorkflowEventBase):
+ """Event model for workflow_deleted"""
+
+ __event_name__ = "workflow_deleted"
+
+ is_public: bool = Field(description="Whether the workflow was shared when it was deleted")
+
+ @classmethod
+ def build(cls, workflow_id: str, user_id: str, is_public: bool) -> "WorkflowDeletedEvent":
+ return cls(workflow_id=workflow_id, user_id=user_id, is_public=is_public)
+
+
class DownloadEventBase(EventBase):
"""Base class for events associated with a download"""
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
new file mode 100644
index 00000000000..2c39bb07096
--- /dev/null
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
@@ -0,0 +1,99 @@
+import { LIST_TAG } from 'services/api';
+import { describe, expect, it, vi } from 'vitest';
+
+import { setEventListeners } from './setEventListeners';
+
+vi.mock('app/logging/logger', () => ({
+ logger: () => ({
+ debug: vi.fn(),
+ trace: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+vi.mock('features/toast/toast', () => ({
+ toast: vi.fn(),
+}));
+
+vi.mock('./onInvocationComplete', () => ({
+ buildOnInvocationComplete: () => vi.fn(),
+}));
+
+vi.mock('./onModelInstallError', () => ({
+ buildOnModelInstallError: () => vi.fn(),
+ DiscordLink: () => null,
+ GitHubIssuesLink: () => null,
+}));
+
+const createMockSocket = () => {
+ const handlers = new Map) => void>();
+
+ return {
+ on: vi.fn((event: string, handler: (...args: Array) => void) => {
+ handlers.set(event, handler);
+ }),
+ emit: vi.fn(),
+ trigger: (event: string, payload?: unknown) => {
+ const handler = handlers.get(event);
+ if (!handler) {
+ throw new Error(`No handler registered for ${event}`);
+ }
+ handler(payload);
+ },
+ };
+};
+
+describe('setEventListeners workflow live updates', () => {
+ it('invalidates workflow list caches on workflow_created', () => {
+ const socket = createMockSocket();
+ const dispatch = vi.fn();
+ const store = {
+ dispatch,
+ getState: vi.fn(() => ({})),
+ };
+
+ setEventListeners({
+ socket: socket as never,
+ store: store as never,
+ setIsConnected: vi.fn(),
+ });
+
+ socket.trigger('workflow_created', { workflow_id: 'wf-1', is_public: true });
+
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.arrayContaining([
+ { type: 'Workflow', id: LIST_TAG },
+ 'WorkflowTags',
+ 'WorkflowTagCounts',
+ 'WorkflowCategoryCounts',
+ ]),
+ })
+ );
+ });
+
+ it('ignores unrelated events for workflow cache invalidation', () => {
+ const socket = createMockSocket();
+ const dispatch = vi.fn();
+ const store = {
+ dispatch,
+ getState: vi.fn(() => ({})),
+ };
+
+ setEventListeners({
+ socket: socket as never,
+ store: store as never,
+ setIsConnected: vi.fn(),
+ });
+
+ socket.trigger('download_started', { source: 'x', download_path: '/tmp/x' });
+
+ expect(dispatch).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.arrayContaining([{ type: 'Workflow', id: LIST_TAG }]),
+ })
+ );
+ });
+});
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
index 59a9ff2afcc..0571e2e2288 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
@@ -98,6 +98,32 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
setIsConnected(false);
});
+ const invalidateWorkflowLibrary = () => {
+ dispatch(
+ api.util.invalidateTags([
+ { type: 'Workflow', id: LIST_TAG },
+ 'WorkflowTags',
+ 'WorkflowTagCounts',
+ 'WorkflowCategoryCounts',
+ ])
+ );
+ };
+
+ socket.on('workflow_created', (data) => {
+ log.debug({ data }, 'Workflow created');
+ invalidateWorkflowLibrary();
+ });
+
+ socket.on('workflow_updated', (data) => {
+ log.debug({ data }, 'Workflow updated');
+ invalidateWorkflowLibrary();
+ });
+
+ socket.on('workflow_deleted', (data) => {
+ log.debug({ data }, 'Workflow deleted');
+ invalidateWorkflowLibrary();
+ });
+
socket.on('invocation_started', (data) => {
if (finishedQueueItemIds.has(data.item_id)) {
return;
diff --git a/invokeai/frontend/web/src/services/events/types.ts b/invokeai/frontend/web/src/services/events/types.ts
index 8937dcc451d..2aae04c35ef 100644
--- a/invokeai/frontend/web/src/services/events/types.ts
+++ b/invokeai/frontend/web/src/services/events/types.ts
@@ -5,6 +5,15 @@ type ClientEmitSubscribeQueue = { queue_id: string };
type ClientEmitUnsubscribeQueue = ClientEmitSubscribeQueue;
type ClientEmitSubscribeBulkDownload = { bulk_download_id: string };
type ClientEmitUnsubscribeBulkDownload = ClientEmitSubscribeBulkDownload;
+type WorkflowCreatedEvent = { workflow_id: string; user_id: string; is_public: boolean; timestamp: number };
+type WorkflowUpdatedEvent = {
+ workflow_id: string;
+ user_id: string;
+ old_is_public: boolean;
+ new_is_public: boolean;
+ timestamp: number;
+};
+type WorkflowDeletedEvent = { workflow_id: string; user_id: string; is_public?: boolean; timestamp?: number };
export type ServerToClientEvents = {
invocation_progress: (payload: S['InvocationProgressEvent']) => void;
@@ -33,6 +42,9 @@ export type ServerToClientEvents = {
bulk_download_started: (payload: S['BulkDownloadStartedEvent']) => void;
bulk_download_complete: (payload: S['BulkDownloadCompleteEvent']) => void;
bulk_download_error: (payload: S['BulkDownloadErrorEvent']) => void;
+ workflow_created: (payload: WorkflowCreatedEvent) => void;
+ workflow_updated: (payload: WorkflowUpdatedEvent) => void;
+ workflow_deleted: (payload: WorkflowDeletedEvent) => void;
};
export type ClientToServerEvents = {
diff --git a/tests/app/routers/test_workflow_live_updates.py b/tests/app/routers/test_workflow_live_updates.py
new file mode 100644
index 00000000000..e98c5ce2311
--- /dev/null
+++ b/tests/app/routers/test_workflow_live_updates.py
@@ -0,0 +1,118 @@
+"""Tests for workflow CRUD live-update events with multiuser visibility rules."""
+
+from typing import Any
+
+from fastapi.testclient import TestClient
+
+from tests.app.routers.test_workflows_multiuser import WORKFLOW_BODY
+
+pytest_plugins = ("tests.app.routers.test_workflows_multiuser",)
+
+
+def _auth(token: str) -> dict[str, str]:
+ return {"Authorization": f"Bearer {token}"}
+
+
+def _event_names(events: list[Any]) -> list[str]:
+ return [event.__event_name__ for event in events]
+
+
+def _get_last_event(events: list[Any], event_name: str) -> Any:
+ matching = [event for event in events if event.__event_name__ == event_name]
+ assert matching, f"Expected event '{event_name}' to be emitted"
+ return matching[-1]
+
+
+def test_create_private_workflow_emits_owner_scoped_created_event(
+ client: TestClient, user1_token: str, mock_invoker: Any
+) -> None:
+ response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
+
+ assert response.status_code == 200
+
+ event = _get_last_event(mock_invoker.services.events.events, "workflow_created")
+ assert event.workflow_id == response.json()["workflow_id"]
+ assert event.user_id == response.json()["user_id"]
+ assert event.is_public is False
+
+
+def test_update_workflow_emits_updated_event_with_previous_visibility(
+ client: TestClient, user1_token: str, mock_invoker: Any
+) -> None:
+ create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
+ workflow_id = create_response.json()["workflow_id"]
+
+ update_response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}",
+ json={"workflow": {**WORKFLOW_BODY, "id": workflow_id, "name": "Renamed Workflow"}},
+ headers=_auth(user1_token),
+ )
+
+ assert update_response.status_code == 200
+
+ event = _get_last_event(mock_invoker.services.events.events, "workflow_updated")
+ assert event.workflow_id == workflow_id
+ assert event.user_id == create_response.json()["user_id"]
+ assert event.old_is_public is False
+ assert event.new_is_public is False
+
+
+def test_update_workflow_is_public_emits_visibility_transition_event(
+ client: TestClient, user1_token: str, mock_invoker: Any
+) -> None:
+ create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
+ workflow_id = create_response.json()["workflow_id"]
+
+ update_response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers=_auth(user1_token),
+ )
+
+ assert update_response.status_code == 200
+
+ event = _get_last_event(mock_invoker.services.events.events, "workflow_updated")
+ assert event.workflow_id == workflow_id
+ assert event.user_id == create_response.json()["user_id"]
+ assert event.old_is_public is False
+ assert event.new_is_public is True
+
+
+def test_delete_workflow_emits_deleted_event_with_last_known_visibility(
+ client: TestClient, user1_token: str, mock_invoker: Any
+) -> None:
+ create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
+ workflow_id = create_response.json()["workflow_id"]
+
+ share_response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers=_auth(user1_token),
+ )
+ assert share_response.status_code == 200
+
+ delete_response = client.delete(f"/api/v1/workflows/i/{workflow_id}", headers=_auth(user1_token))
+
+ assert delete_response.status_code == 200
+
+ event = _get_last_event(mock_invoker.services.events.events, "workflow_deleted")
+ assert event.workflow_id == workflow_id
+ assert event.user_id == create_response.json()["user_id"]
+ assert event.is_public is True
+
+
+def test_failed_update_does_not_emit_workflow_live_update_event(
+ client: TestClient, user1_token: str, user2_token: str, mock_invoker: Any
+) -> None:
+ create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
+ workflow_id = create_response.json()["workflow_id"]
+ before_event_names = _event_names(mock_invoker.services.events.events)
+
+ update_response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}",
+ json={"workflow": {**WORKFLOW_BODY, "id": workflow_id, "name": "Hijacked"}},
+ headers=_auth(user2_token),
+ )
+
+ assert update_response.status_code == 403
+ assert _event_names(mock_invoker.services.events.events) == before_event_names
diff --git a/tests/app/test_workflow_socketio.py b/tests/app/test_workflow_socketio.py
new file mode 100644
index 00000000000..2a18544660f
--- /dev/null
+++ b/tests/app/test_workflow_socketio.py
@@ -0,0 +1,126 @@
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import FastAPI
+
+from invokeai.app.api.sockets import SocketIO
+
+
+@pytest.mark.anyio
+async def test_authenticated_user_joins_workflow_rooms_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
+ socketio = SocketIO(FastAPI())
+ socketio._sio.enter_room = AsyncMock()
+
+ monkeypatch.setattr(
+ "invokeai.app.api.sockets.verify_token",
+ lambda token: SimpleNamespace(user_id="user-1", is_admin=False) if token == "valid-token" else None,
+ )
+
+ accepted = await socketio._handle_connect("sid-1", {}, {"token": "valid-token"})
+
+ assert accepted is True
+ socketio._sio.enter_room.assert_any_call("sid-1", "user:user-1")
+ socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
+
+
+@pytest.mark.anyio
+async def test_admin_joins_admin_room_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
+ socketio = SocketIO(FastAPI())
+ socketio._sio.enter_room = AsyncMock()
+
+ monkeypatch.setattr(
+ "invokeai.app.api.sockets.verify_token",
+ lambda token: SimpleNamespace(user_id="admin-1", is_admin=True) if token == "valid-token" else None,
+ )
+
+ accepted = await socketio._handle_connect("sid-1", {}, {"token": "valid-token"})
+
+ assert accepted is True
+ socketio._sio.enter_room.assert_any_call("sid-1", "user:admin-1")
+ socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
+ socketio._sio.enter_room.assert_any_call("sid-1", "admin")
+
+
+@pytest.mark.anyio
+async def test_private_workflow_event_is_emitted_only_to_owner_and_admin() -> None:
+ socketio = SocketIO(FastAPI())
+ socketio._sio.emit = AsyncMock()
+
+ event_payload = SimpleNamespace(
+ __event_name__="workflow_created",
+ workflow_id="wf-1",
+ user_id="owner-1",
+ is_public=False,
+ model_dump=lambda mode="json": {"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
+ )
+
+ await socketio._handle_workflow_event(("workflow_created", event_payload))
+
+ socketio._sio.emit.assert_any_call(
+ event="workflow_created",
+ data={"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
+ room="user:owner-1",
+ )
+ socketio._sio.emit.assert_any_call(
+ event="workflow_created",
+ data={"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
+ room="admin",
+ )
+ assert socketio._sio.emit.await_count == 2
+
+
+@pytest.mark.anyio
+async def test_shared_workflow_event_is_emitted_to_shared_room() -> None:
+ socketio = SocketIO(FastAPI())
+ socketio._sio.emit = AsyncMock()
+
+ event_payload = SimpleNamespace(
+ __event_name__="workflow_updated",
+ workflow_id="wf-1",
+ user_id="owner-1",
+ old_is_public=False,
+ new_is_public=True,
+ model_dump=lambda mode="json": {
+ "workflow_id": "wf-1",
+ "user_id": "owner-1",
+ "old_is_public": False,
+ "new_is_public": True,
+ },
+ )
+
+ await socketio._handle_workflow_event(("workflow_updated", event_payload))
+
+ socketio._sio.emit.assert_any_call(
+ event="workflow_updated",
+ data={"workflow_id": "wf-1", "user_id": "owner-1", "old_is_public": False, "new_is_public": True},
+ room="workflows:shared",
+ )
+
+
+@pytest.mark.anyio
+async def test_shared_to_private_transition_emits_removal_to_shared_room() -> None:
+ socketio = SocketIO(FastAPI())
+ socketio._sio.emit = AsyncMock()
+
+ event_payload = SimpleNamespace(
+ __event_name__="workflow_updated",
+ workflow_id="wf-1",
+ user_id="owner-1",
+ old_is_public=True,
+ new_is_public=False,
+ model_dump=lambda mode="json": {
+ "workflow_id": "wf-1",
+ "user_id": "owner-1",
+ "old_is_public": True,
+ "new_is_public": False,
+ },
+ )
+
+ await socketio._handle_workflow_event(("workflow_updated", event_payload))
+
+ socketio._sio.emit.assert_any_call(
+ event="workflow_deleted",
+ data={"workflow_id": "wf-1"},
+ room="workflows:shared",
+ )
From 55b050ed908c87ec899d83725c7020e0f2b713d0 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 13:56:58 -0500
Subject: [PATCH 013/100] Add persisted call-saved-workflows node stub
---
.../app/invocations/call_saved_workflows.py | 26 ++++
.../Invocation/CallSavedWorkflowsNode.tsx | 123 ++++++++++++++++++
.../Invocation/InvocationNodeWrapper.tsx | 9 +-
.../getInvocationNodeBodyComponent.test.ts | 13 ++
.../getInvocationNodeBodyComponent.ts | 9 ++
.../features/nodes/store/util/testUtils.ts | 101 ++++++++++++++
.../nodes/util/schema/parseSchema.test.ts | 6 +-
.../invocations/test_call_saved_workflows.py | 29 +++++
tests/conftest.py | 3 +-
9 files changed, 316 insertions(+), 3 deletions(-)
create mode 100644 invokeai/app/invocations/call_saved_workflows.py
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.test.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.ts
create mode 100644 tests/app/invocations/test_call_saved_workflows.py
diff --git a/invokeai/app/invocations/call_saved_workflows.py b/invokeai/app/invocations/call_saved_workflows.py
new file mode 100644
index 00000000000..c4d51dab9ff
--- /dev/null
+++ b/invokeai/app/invocations/call_saved_workflows.py
@@ -0,0 +1,26 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import InputField
+from invokeai.app.invocations.primitives import IntegerOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+@invocation(
+ "call_saved_workflows",
+ title="Call Saved Workflows",
+ tags=["workflow", "saved", "library"],
+ category="workflow",
+ version="1.0.0",
+ use_cache=False,
+ classification=Classification.Beta,
+)
+class CallSavedWorkflowsInvocation(BaseInvocation):
+ """Displays and later executes against the saved workflow library."""
+
+ workflow_id: str = InputField(
+ default="",
+ description="The selected saved workflow ID, managed by the workflow editor UI.",
+ ui_hidden=True,
+ )
+
+ def invoke(self, context: InvocationContext) -> IntegerOutput:
+ return IntegerOutput(value=0)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
new file mode 100644
index 00000000000..13db7076e0e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
@@ -0,0 +1,123 @@
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Badge, Flex, Spinner, Text } from '@invoke-ai/ui-library';
+import { EMPTY_ARRAY } from 'app/store/constants';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
+import { memo, useMemo } from 'react';
+import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
+import type { S } from 'services/api/types';
+
+import InvocationNodeHeader from './InvocationNodeHeader';
+
+type Props = {
+ nodeId: string;
+ isOpen: boolean;
+};
+
+const bodySx: SystemStyleObject = {
+ flexDirection: 'column',
+ w: 'full',
+ h: 'full',
+ py: 2,
+ gap: 2,
+ borderBottomRadius: 'base',
+ '&[data-is-open="false"]': {
+ display: 'none',
+ },
+};
+
+const queryArg = {
+ page: 0,
+ per_page: 50,
+ order_by: 'name',
+ direction: 'ASC',
+ categories: ['user', 'default'],
+ query: '',
+ tags: [],
+ has_been_opened: undefined,
+ is_public: undefined,
+} satisfies Parameters[0];
+
+const queryOptions = {
+ selectFromResult: ({ data, ...rest }) => ({
+ items: data?.pages.flatMap(({ items }) => items) ?? EMPTY_ARRAY,
+ ...rest,
+ }),
+} satisfies Parameters[1];
+
+const CallSavedWorkflowsNode = ({ nodeId, isOpen }: Props) => {
+ const { items, isLoading, isFetching } = useListWorkflowsInfiniteInfiniteQuery(queryArg, queryOptions);
+
+ return (
+ <>
+
+
+
+
+
+ Saved Workflows
+
+ {items.length}
+
+ {isLoading ? : }
+
+
+ >
+ );
+};
+
+export default memo(CallSavedWorkflowsNode);
+
+const LoadingState = memo(() => {
+ return (
+
+
+
+ );
+});
+LoadingState.displayName = 'LoadingState';
+
+const WorkflowItems = memo(
+ ({ items, isFetching }: { items: S['WorkflowRecordListItemWithThumbnailDTO'][]; isFetching: boolean }) => {
+ const visibleItems = useMemo(() => items.slice(0, 8), [items]);
+
+ if (visibleItems.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {visibleItems.map((workflow) => (
+
+
+ {workflow.name}
+
+
+ {workflow.category === 'default' && Default}
+ {workflow.is_public && workflow.category !== 'default' && Shared}
+
+
+ ))}
+ {items.length > visibleItems.length && (
+
+ Showing {visibleItems.length} of {items.length} workflows
+
+ )}
+ {isFetching && (
+
+ Updating...
+
+ )}
+
+ );
+ }
+);
+WorkflowItems.displayName = 'WorkflowItems';
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
index 7a4ea9ca65c..84bfb2179eb 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
@@ -9,7 +9,9 @@ import { selectNodes } from 'features/nodes/store/selectors';
import type { InvocationNodeData } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
+import CallSavedWorkflowsNode from './CallSavedWorkflowsNode';
import { InvocationNodeContextProvider } from './context';
+import { getInvocationNodeBodyComponentKey } from './getInvocationNodeBodyComponent';
import InvocationNodeUnknownFallback from './InvocationNodeUnknownFallback';
const InvocationNodeWrapper = (props: NodeProps>) => {
@@ -17,6 +19,7 @@ const InvocationNodeWrapper = (props: NodeProps>) => {
const { id: nodeId, type, isOpen, label } = data;
const templates = useStore($templates);
const hasTemplate = useMemo(() => Boolean(templates[type]), [templates, type]);
+ const bodyComponentKey = useMemo(() => getInvocationNodeBodyComponentKey(type), [type]);
const selectNodeExists = useMemo(
() => createSelector(selectNodes, (nodes) => Boolean(nodes.find((n) => n.id === nodeId))),
[nodeId]
@@ -40,7 +43,11 @@ const InvocationNodeWrapper = (props: NodeProps>) => {
return (
-
+ {bodyComponentKey === 'call_saved_workflows' ? (
+
+ ) : (
+
+ )}
);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.test.ts
new file mode 100644
index 00000000000..bce95f52d91
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+
+import { getInvocationNodeBodyComponentKey } from './getInvocationNodeBodyComponent';
+
+describe('getInvocationNodeBodyComponentKey', () => {
+ it('returns the specialized renderer for call_saved_workflows nodes', () => {
+ expect(getInvocationNodeBodyComponentKey('call_saved_workflows')).toBe('call_saved_workflows');
+ });
+
+ it('falls back to the default renderer for other invocation nodes', () => {
+ expect(getInvocationNodeBodyComponentKey('add')).toBe('default');
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.ts
new file mode 100644
index 00000000000..355cc6630d3
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.ts
@@ -0,0 +1,9 @@
+export type InvocationNodeBodyComponentKey = 'default' | 'call_saved_workflows';
+
+export const getInvocationNodeBodyComponentKey = (type: string): InvocationNodeBodyComponentKey => {
+ if (type === 'call_saved_workflows') {
+ return 'call_saved_workflows';
+ }
+
+ return 'default';
+};
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
index 1eb445beaf7..f40a29f9558 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
@@ -72,6 +72,49 @@ export const add: InvocationTemplate = {
classification: 'stable',
};
+export const call_saved_workflows: InvocationTemplate = {
+ title: 'Call Saved Workflows',
+ type: 'call_saved_workflows',
+ version: '1.0.0',
+ tags: ['workflow', 'saved', 'library'],
+ description: 'Displays and later executes against the saved workflow library.',
+ outputType: 'integer_output',
+ inputs: {
+ workflow_id: {
+ name: 'workflow_id',
+ title: 'Workflow Id',
+ required: false,
+ description: 'The selected saved workflow ID, managed by the workflow editor UI.',
+ fieldKind: 'input',
+ input: 'any',
+ ui_hidden: true,
+ type: {
+ name: 'StringField',
+ cardinality: 'SINGLE',
+ batch: false,
+ },
+ default: '',
+ },
+ },
+ outputs: {
+ value: {
+ fieldKind: 'output',
+ name: 'value',
+ title: 'Value',
+ description: 'The output integer',
+ type: {
+ name: 'IntegerField',
+ cardinality: 'SINGLE',
+ batch: false,
+ },
+ ui_hidden: false,
+ },
+ },
+ useCache: false,
+ nodePack: 'invokeai',
+ classification: 'beta',
+};
+
export const sub: InvocationTemplate = {
title: 'Subtract Integers',
type: 'sub',
@@ -530,6 +573,7 @@ const iterate: InvocationTemplate = {
export const templates: Templates = {
add,
+ call_saved_workflows,
sub,
collect,
iterate,
@@ -547,6 +591,63 @@ export const schema = {
},
components: {
schemas: {
+ CallSavedWorkflowsInvocation: {
+ properties: {
+ id: {
+ type: 'string',
+ title: 'Id',
+ description: 'The id of this instance of an invocation. Must be unique among all instances of invocations.',
+ field_kind: 'node_attribute',
+ },
+ is_intermediate: {
+ type: 'boolean',
+ title: 'Is Intermediate',
+ description: 'Whether or not this is an intermediate invocation.',
+ default: false,
+ field_kind: 'node_attribute',
+ ui_type: 'IsIntermediate',
+ },
+ use_cache: {
+ type: 'boolean',
+ title: 'Use Cache',
+ description: 'Whether or not to use the cache',
+ default: false,
+ field_kind: 'node_attribute',
+ },
+ workflow_id: {
+ type: 'string',
+ title: 'Workflow Id',
+ description: 'The selected saved workflow ID, managed by the workflow editor UI.',
+ default: '',
+ field_kind: 'input',
+ input: 'any',
+ orig_default: '',
+ orig_required: false,
+ ui_hidden: true,
+ },
+ type: {
+ type: 'string',
+ enum: ['call_saved_workflows'],
+ const: 'call_saved_workflows',
+ title: 'type',
+ default: 'call_saved_workflows',
+ field_kind: 'node_attribute',
+ },
+ },
+ type: 'object',
+ required: ['type', 'id'],
+ title: 'Call Saved Workflows',
+ description: 'Displays and later executes against the saved workflow library.',
+ category: 'workflow',
+ classification: 'beta',
+ node_pack: 'invokeai',
+ tags: ['workflow', 'saved', 'library'],
+ version: '1.0.0',
+ output: {
+ $ref: '#/components/schemas/IntegerOutput',
+ },
+ class: 'invocation',
+ },
AddInvocation: {
properties: {
id: {
diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
index 45dd0f79a34..47d01dbfc4e 100644
--- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
@@ -1,5 +1,5 @@
import { omit, pick } from 'es-toolkit/compat';
-import { schema, templates } from 'features/nodes/store/util/testUtils';
+import { call_saved_workflows, schema, templates } from 'features/nodes/store/util/testUtils';
import { parseSchema } from 'features/nodes/util/schema/parseSchema';
import { describe, expect, it } from 'vitest';
@@ -18,4 +18,8 @@ describe('parseSchema', () => {
const parsed = parseSchema(schema, ['add']);
expect(stripUndefinedDeep(parsed)).toEqual(stripUndefinedDeep(pick(templates, 'add')));
});
+ it('should parse the call_saved_workflows node template', () => {
+ const parsed = parseSchema(schema);
+ expect(stripUndefinedDeep(parsed.call_saved_workflows)).toEqual(stripUndefinedDeep(call_saved_workflows));
+ });
});
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
new file mode 100644
index 00000000000..a8e62bfc2da
--- /dev/null
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -0,0 +1,29 @@
+from unittest.mock import Mock
+
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+def test_call_saved_workflows_invocation_contract():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+ from invokeai.app.invocations.primitives import IntegerOutput
+
+ invocation = CallSavedWorkflowsInvocation(id="test-node")
+
+ assert invocation.get_type() == "call_saved_workflows"
+ assert invocation.workflow_id == ""
+
+ output = invocation.invoke(Mock(InvocationContext))
+
+ assert isinstance(output, IntegerOutput)
+ assert output.value == 0
+
+
+def test_call_saved_workflows_invocation_schema_hides_editor_managed_fields():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+
+ schema = CallSavedWorkflowsInvocation.model_json_schema()
+ workflow_id = schema["properties"]["workflow_id"]
+
+ assert workflow_id["default"] == ""
+ assert workflow_id["ui_hidden"] is True
+ assert workflow_id["input"] == "any"
diff --git a/tests/conftest.py b/tests/conftest.py
index 980a99611ab..255c5efb72a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -23,6 +23,7 @@
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_default import UserService
+from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from invokeai.backend.util.logging import InvokeAILogger
from tests.backend.model_manager.model_manager_fixtures import * # noqa: F403
from tests.fixtures.sqlite_database import create_mock_sqlite_database # noqa: F401
@@ -57,7 +58,7 @@ def mock_services() -> InvocationServices:
session_processor=None, # type: ignore
session_queue=None, # type: ignore
urls=None, # type: ignore
- workflow_records=None, # type: ignore
+ workflow_records=SqliteWorkflowRecordsStorage(db=db),
tensors=None, # type: ignore
conditioning=None, # type: ignore
style_preset_records=None, # type: ignore
From a4e3ac55e59335e3249f1dda67ce05ef74c9b3a9 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 15:12:56 -0500
Subject: [PATCH 014/100] Add saved workflow picker state handling
---
.../app/invocations/call_saved_workflows.py | 3 +
.../Invocation/CallSavedWorkflowsNode.tsx | 155 ++++++++++++++----
.../callSavedWorkflowsNodeUtils.test.ts | 76 +++++++++
.../Invocation/callSavedWorkflowsNodeUtils.ts | 72 ++++++++
.../nodes/util/workflow/buildWorkflow.test.ts | 35 ++++
.../services/events/setEventListeners.test.ts | 47 ++++++
.../src/services/events/setEventListeners.tsx | 22 +++
.../invocations/test_call_saved_workflows.py | 15 +-
8 files changed, 391 insertions(+), 34 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
diff --git a/invokeai/app/invocations/call_saved_workflows.py b/invokeai/app/invocations/call_saved_workflows.py
index c4d51dab9ff..ce14b0d6787 100644
--- a/invokeai/app/invocations/call_saved_workflows.py
+++ b/invokeai/app/invocations/call_saved_workflows.py
@@ -23,4 +23,7 @@ class CallSavedWorkflowsInvocation(BaseInvocation):
)
def invoke(self, context: InvocationContext) -> IntegerOutput:
+ if not self.workflow_id:
+ raise ValueError("A saved workflow must be selected before executing call_saved_workflows.")
+
return IntegerOutput(value=0)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
index 13db7076e0e..92b46985046 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
@@ -1,11 +1,22 @@
-import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Badge, Flex, Spinner, Text } from '@invoke-ai/ui-library';
+import type { ComboboxOnChange, ComboboxOption, SystemStyleObject } from '@invoke-ai/ui-library';
+import { Badge, Combobox, Flex, FormControl, Spinner, Text } from '@invoke-ai/ui-library';
+import { createSelector } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
-import { memo, useMemo } from 'react';
+import { fieldValueReset } from 'features/nodes/store/nodesSlice';
+import { selectNodesSlice } from 'features/nodes/store/selectors';
+import { NO_DRAG_CLASS, NO_FIT_ON_DOUBLE_CLICK_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
import type { S } from 'services/api/types';
+import {
+ buildSavedWorkflowOptions,
+ getSavedWorkflowSelectionState,
+ getSelectedWorkflowOption,
+} from './callSavedWorkflowsNodeUtils';
import InvocationNodeHeader from './InvocationNodeHeader';
type Props = {
@@ -45,7 +56,41 @@ const queryOptions = {
} satisfies Parameters[1];
const CallSavedWorkflowsNode = ({ nodeId, isOpen }: Props) => {
+ const dispatch = useAppDispatch();
const { items, isLoading, isFetching } = useListWorkflowsInfiniteInfiniteQuery(queryArg, queryOptions);
+ const selectWorkflowId = useMemo(
+ () =>
+ createSelector(selectNodesSlice, (nodes) => {
+ const node = nodes.nodes.find((node) => node.id === nodeId);
+ if (node?.type !== 'invocation') {
+ return '';
+ }
+
+ const workflowId = node.data.inputs.workflow_id?.value;
+ return typeof workflowId === 'string' ? workflowId : '';
+ }),
+ [nodeId]
+ );
+ const workflowId = useAppSelector(selectWorkflowId);
+ const options = useMemo(() => buildSavedWorkflowOptions(items), [items]);
+ const selectionState = useMemo(() => getSavedWorkflowSelectionState(items, workflowId), [items, workflowId]);
+ const selectedOption = useMemo(
+ () => getSelectedWorkflowOption(items, workflowId, 'Missing workflow'),
+ [items, workflowId]
+ );
+
+ const onChange = useCallback(
+ (value) => {
+ dispatch(
+ fieldValueReset({
+ nodeId,
+ fieldName: 'workflow_id',
+ value: value?.value ?? '',
+ })
+ );
+ },
+ [dispatch, nodeId]
+ );
return (
<>
@@ -58,7 +103,17 @@ const CallSavedWorkflowsNode = ({ nodeId, isOpen }: Props) => {
{items.length}
- {isLoading ? : }
+ {isLoading ? (
+
+ ) : (
+
+ )}
>
@@ -76,40 +131,54 @@ const LoadingState = memo(() => {
});
LoadingState.displayName = 'LoadingState';
-const WorkflowItems = memo(
- ({ items, isFetching }: { items: S['WorkflowRecordListItemWithThumbnailDTO'][]; isFetching: boolean }) => {
- const visibleItems = useMemo(() => items.slice(0, 8), [items]);
+const WorkflowPicker = memo(
+ ({
+ options,
+ selectionState,
+ selectedOption,
+ isFetching,
+ onChange,
+ }: {
+ options: ComboboxOption[];
+ selectionState:
+ | { status: 'unselected' }
+ | { status: 'selected'; workflow: S['WorkflowRecordListItemWithThumbnailDTO'] }
+ | { status: 'missing'; workflowId: string };
+ selectedOption: ComboboxOption | null;
+ isFetching: boolean;
+ onChange: ComboboxOnChange;
+ }) => {
+ const { t } = useTranslation();
+ const noOptionsMessage = useCallback(() => t('nodes.noMatchingWorkflows'), [t]);
- if (visibleItems.length === 0) {
- return ;
+ if (options.length === 0) {
+ return ;
}
return (
- {visibleItems.map((workflow) => (
-
-
- {workflow.name}
+
+
+
+ {selectionState.status === 'selected' ? (
+
+
+ {selectionState.workflow.name}
-
- {workflow.category === 'default' && Default}
- {workflow.is_public && workflow.category !== 'default' && Shared}
-
+ {selectionState.workflow.category === 'default' && Default}
+ {selectionState.workflow.is_public && selectionState.workflow.category !== 'default' && (
+ Shared
+ )}
- ))}
- {items.length > visibleItems.length && (
-
- Showing {visibleItems.length} of {items.length} workflows
-
+ ) : (
+
)}
{isFetching && (
@@ -120,4 +189,26 @@ const WorkflowItems = memo(
);
}
);
-WorkflowItems.displayName = 'WorkflowItems';
+WorkflowPicker.displayName = 'WorkflowPicker';
+
+const SelectionStatusBadge = memo(
+ ({
+ selectionState,
+ }: {
+ selectionState:
+ | { status: 'unselected' }
+ | { status: 'selected'; workflow: S['WorkflowRecordListItemWithThumbnailDTO'] }
+ | { status: 'missing'; workflowId: string };
+ }) => {
+ if (selectionState.status === 'selected') {
+ return null;
+ }
+
+ return (
+
+ {selectionState.status === 'missing' ? 'Missing workflow' : 'Choose a workflow'}
+
+ );
+ }
+);
+SelectionStatusBadge.displayName = 'SelectionStatusBadge';
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
new file mode 100644
index 00000000000..47f0a3444f2
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
@@ -0,0 +1,76 @@
+import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildSavedWorkflowOptions,
+ getSavedWorkflowSelectionState,
+ getSelectedWorkflow,
+ getSelectedWorkflowOption,
+ MISSING_WORKFLOW_OPTION_VALUE,
+} from './callSavedWorkflowsNodeUtils';
+
+const workflows: WorkflowRecordListItemWithThumbnailDTO[] = [
+ {
+ workflow_id: 'workflow-a',
+ name: 'Alpha Workflow',
+ created_at: '',
+ updated_at: '',
+ opened_at: null,
+ description: '',
+ tags: '',
+ is_public: false,
+ thumbnail_url: null,
+ category: 'user',
+ user_id: 'user-a',
+ },
+ {
+ workflow_id: 'workflow-b',
+ name: 'Beta Workflow',
+ created_at: '',
+ updated_at: '',
+ opened_at: null,
+ description: '',
+ tags: '',
+ is_public: true,
+ thumbnail_url: null,
+ category: 'default',
+ user_id: 'system',
+ },
+];
+
+describe('callSavedWorkflowsNodeUtils', () => {
+ it('builds combobox options from visible workflows', () => {
+ expect(buildSavedWorkflowOptions(workflows)).toEqual([
+ { label: 'Alpha Workflow', value: 'workflow-a' },
+ { label: 'Beta Workflow', value: 'workflow-b' },
+ ]);
+ });
+
+ it('resolves the selected workflow for a stored workflow id', () => {
+ expect(getSelectedWorkflow(workflows, 'workflow-b')).toEqual(workflows[1]);
+ });
+
+ it('returns null when no workflow is selected', () => {
+ expect(getSavedWorkflowSelectionState(workflows, '')).toEqual({ status: 'unselected' });
+ expect(getSelectedWorkflow(workflows, '')).toBeNull();
+ expect(getSelectedWorkflowOption(workflows, '', 'Missing workflow')).toBeNull();
+ });
+
+ it('returns a selected state when the workflow id resolves', () => {
+ expect(getSavedWorkflowSelectionState(workflows, 'workflow-a')).toEqual({
+ status: 'selected',
+ workflow: workflows[0],
+ });
+ });
+
+ it('returns a synthetic missing option when the stored workflow id is stale', () => {
+ expect(getSavedWorkflowSelectionState(workflows, 'missing-workflow')).toEqual({
+ status: 'missing',
+ workflowId: 'missing-workflow',
+ });
+ expect(getSelectedWorkflowOption(workflows, 'missing-workflow', 'Missing workflow')).toEqual({
+ label: 'Missing workflow',
+ value: MISSING_WORKFLOW_OPTION_VALUE,
+ });
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.ts
new file mode 100644
index 00000000000..43e2b1a7c11
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.ts
@@ -0,0 +1,72 @@
+import type { ComboboxOption } from '@invoke-ai/ui-library';
+import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
+
+export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
+
+export type SavedWorkflowSelectionState =
+ | { status: 'unselected' }
+ | { status: 'selected'; workflow: WorkflowRecordListItemWithThumbnailDTO }
+ | { status: 'missing'; workflowId: string };
+
+export const buildSavedWorkflowOptions = (
+ workflows: WorkflowRecordListItemWithThumbnailDTO[]
+): ComboboxOption[] => {
+ return workflows.map((workflow) => ({
+ label: workflow.name,
+ value: workflow.workflow_id,
+ }));
+};
+
+export const getSelectedWorkflow = (
+ workflows: WorkflowRecordListItemWithThumbnailDTO[],
+ workflowId: string
+): WorkflowRecordListItemWithThumbnailDTO | null => {
+ const selectionState = getSavedWorkflowSelectionState(workflows, workflowId);
+
+ if (selectionState.status !== 'selected') {
+ return null;
+ }
+
+ return selectionState.workflow;
+};
+
+export const getSavedWorkflowSelectionState = (
+ workflows: WorkflowRecordListItemWithThumbnailDTO[],
+ workflowId: string
+): SavedWorkflowSelectionState => {
+ if (!workflowId) {
+ return { status: 'unselected' };
+ }
+
+ const workflow = workflows.find((workflow) => workflow.workflow_id === workflowId);
+
+ if (workflow) {
+ return { status: 'selected', workflow };
+ }
+
+ return { status: 'missing', workflowId };
+};
+
+export const getSelectedWorkflowOption = (
+ workflows: WorkflowRecordListItemWithThumbnailDTO[],
+ workflowId: string,
+ missingLabel: string
+): ComboboxOption | null => {
+ const selectionState = getSavedWorkflowSelectionState(workflows, workflowId);
+
+ if (selectionState.status === 'unselected') {
+ return null;
+ }
+
+ if (selectionState.status === 'selected') {
+ return {
+ label: selectionState.workflow.name,
+ value: selectionState.workflow.workflow_id,
+ };
+ }
+
+ return {
+ label: missingLabel,
+ value: MISSING_WORKFLOW_OPTION_VALUE,
+ };
+};
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
new file mode 100644
index 00000000000..2ceb2eda3e7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
@@ -0,0 +1,35 @@
+import { call_saved_workflows, buildNode } from 'features/nodes/store/util/testUtils';
+import { getInitialWorkflow } from 'features/nodes/store/nodesSlice';
+import { describe, expect, it } from 'vitest';
+
+describe('buildWorkflowFast', () => {
+ it('persists the selected workflow id for call_saved_workflows nodes', async () => {
+ Object.assign(globalThis, {
+ window: {
+ location: {
+ origin: 'http://localhost',
+ },
+ },
+ });
+
+ const { buildWorkflowFast } = await import('features/nodes/util/workflow/buildWorkflow');
+ const node = buildNode(call_saved_workflows);
+ node.data.inputs.workflow_id.value = 'workflow-123';
+
+ const workflow = buildWorkflowFast({
+ _version: 1,
+ formFieldInitialValues: {},
+ ...getInitialWorkflow(),
+ nodes: [node],
+ edges: [],
+ });
+
+ expect(workflow.nodes).toHaveLength(1);
+ expect(workflow.nodes[0]?.type).toBe('invocation');
+ if (workflow.nodes[0]?.type !== 'invocation') {
+ throw new Error('Expected invocation node');
+ }
+ expect(workflow.nodes[0].data.type).toBe('call_saved_workflows');
+ expect(workflow.nodes[0].data.inputs.workflow_id.value).toBe('workflow-123');
+ });
+});
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
index 2c39bb07096..a16928aed60 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
@@ -96,4 +96,51 @@ describe('setEventListeners workflow live updates', () => {
})
);
});
+
+ it('clears selected workflow ids from call_saved_workflows nodes on workflow_deleted', () => {
+ const socket = createMockSocket();
+ const dispatch = vi.fn();
+ const store = {
+ dispatch,
+ getState: vi.fn(() => ({
+ nodes: {
+ present: {
+ nodes: [
+ {
+ id: 'call-saved-workflows-node',
+ type: 'invocation',
+ data: {
+ id: 'call-saved-workflows-node',
+ type: 'call_saved_workflows',
+ inputs: {
+ workflow_id: {
+ value: 'wf-1',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ })),
+ };
+
+ setEventListeners({
+ socket: socket as never,
+ store: store as never,
+ setIsConnected: vi.fn(),
+ });
+
+ socket.trigger('workflow_deleted', { workflow_id: 'wf-1', is_public: false });
+
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ nodeId: 'call-saved-workflows-node',
+ fieldName: 'workflow_id',
+ value: '',
+ }),
+ })
+ );
+ });
});
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
index 0571e2e2288..bbed2264800 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
@@ -25,6 +25,8 @@ import type {
} from 'features/controlLayers/store/types';
import { getControlLayerState, getReferenceImageState } from 'features/controlLayers/store/util';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
+import { fieldValueReset } from 'features/nodes/store/nodesSlice';
+import { selectNodesSlice } from 'features/nodes/store/selectors';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { modelSelected } from 'features/parameters/store/actions';
import ErrorToastDescription, { getTitle } from 'features/toast/ErrorToastDescription';
@@ -122,6 +124,26 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
socket.on('workflow_deleted', (data) => {
log.debug({ data }, 'Workflow deleted');
invalidateWorkflowLibrary();
+
+ const nodes = selectNodesSlice(getState()).nodes;
+
+ for (const node of nodes) {
+ if (node.type !== 'invocation' || node.data.type !== 'call_saved_workflows') {
+ continue;
+ }
+
+ if (node.data.inputs.workflow_id?.value !== data.workflow_id) {
+ continue;
+ }
+
+ dispatch(
+ fieldValueReset({
+ nodeId: node.id,
+ fieldName: 'workflow_id',
+ value: '',
+ })
+ );
+ }
});
socket.on('invocation_started', (data) => {
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index a8e62bfc2da..72a72787117 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -1,5 +1,7 @@
from unittest.mock import Mock
+import pytest
+
from invokeai.app.services.shared.invocation_context import InvocationContext
@@ -7,10 +9,10 @@ def test_call_saved_workflows_invocation_contract():
from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
from invokeai.app.invocations.primitives import IntegerOutput
- invocation = CallSavedWorkflowsInvocation(id="test-node")
+ invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="workflow-123")
assert invocation.get_type() == "call_saved_workflows"
- assert invocation.workflow_id == ""
+ assert invocation.workflow_id == "workflow-123"
output = invocation.invoke(Mock(InvocationContext))
@@ -18,6 +20,15 @@ def test_call_saved_workflows_invocation_contract():
assert output.value == 0
+def test_call_saved_workflows_invocation_raises_when_workflow_id_is_empty():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+
+ invocation = CallSavedWorkflowsInvocation(id="test-node")
+
+ with pytest.raises(ValueError, match="saved workflow must be selected"):
+ invocation.invoke(Mock(InvocationContext))
+
+
def test_call_saved_workflows_invocation_schema_hides_editor_managed_fields():
from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
From 5525ac0dbdc5d52d18612d8508a2a5d42e1caa9f Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 15:16:45 -0500
Subject: [PATCH 015/100] Clarify saved workflow missing state
---
.../Invocation/CallSavedWorkflowsNode.tsx | 6 +-
.../callSavedWorkflowsNodeUtils.test.ts | 6 +-
.../services/events/setEventListeners.test.ts | 57 +++++++++++++++++++
3 files changed, 64 insertions(+), 5 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
index 92b46985046..b19892ddc5c 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
@@ -19,6 +19,8 @@ import {
} from './callSavedWorkflowsNodeUtils';
import InvocationNodeHeader from './InvocationNodeHeader';
+const MISSING_SELECTION_LABEL = 'Missing or inaccessible workflow';
+
type Props = {
nodeId: string;
isOpen: boolean;
@@ -75,7 +77,7 @@ const CallSavedWorkflowsNode = ({ nodeId, isOpen }: Props) => {
const options = useMemo(() => buildSavedWorkflowOptions(items), [items]);
const selectionState = useMemo(() => getSavedWorkflowSelectionState(items, workflowId), [items, workflowId]);
const selectedOption = useMemo(
- () => getSelectedWorkflowOption(items, workflowId, 'Missing workflow'),
+ () => getSelectedWorkflowOption(items, workflowId, MISSING_SELECTION_LABEL),
[items, workflowId]
);
@@ -206,7 +208,7 @@ const SelectionStatusBadge = memo(
return (
- {selectionState.status === 'missing' ? 'Missing workflow' : 'Choose a workflow'}
+ {selectionState.status === 'missing' ? MISSING_SELECTION_LABEL : 'Choose a workflow'}
);
}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
index 47f0a3444f2..d1d0d426e7a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
@@ -53,7 +53,7 @@ describe('callSavedWorkflowsNodeUtils', () => {
it('returns null when no workflow is selected', () => {
expect(getSavedWorkflowSelectionState(workflows, '')).toEqual({ status: 'unselected' });
expect(getSelectedWorkflow(workflows, '')).toBeNull();
- expect(getSelectedWorkflowOption(workflows, '', 'Missing workflow')).toBeNull();
+ expect(getSelectedWorkflowOption(workflows, '', 'Missing or inaccessible workflow')).toBeNull();
});
it('returns a selected state when the workflow id resolves', () => {
@@ -68,8 +68,8 @@ describe('callSavedWorkflowsNodeUtils', () => {
status: 'missing',
workflowId: 'missing-workflow',
});
- expect(getSelectedWorkflowOption(workflows, 'missing-workflow', 'Missing workflow')).toEqual({
- label: 'Missing workflow',
+ expect(getSelectedWorkflowOption(workflows, 'missing-workflow', 'Missing or inaccessible workflow')).toEqual({
+ label: 'Missing or inaccessible workflow',
value: MISSING_WORKFLOW_OPTION_VALUE,
});
});
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
index a16928aed60..35b6f568bb7 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
@@ -143,4 +143,61 @@ describe('setEventListeners workflow live updates', () => {
})
);
});
+
+ it('does not clear selected workflow ids from call_saved_workflows nodes on workflow_updated', () => {
+ const socket = createMockSocket();
+ const dispatch = vi.fn();
+ const store = {
+ dispatch,
+ getState: vi.fn(() => ({
+ nodes: {
+ present: {
+ nodes: [
+ {
+ id: 'call-saved-workflows-node',
+ type: 'invocation',
+ data: {
+ id: 'call-saved-workflows-node',
+ type: 'call_saved_workflows',
+ inputs: {
+ workflow_id: {
+ value: 'wf-1',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ })),
+ };
+
+ setEventListeners({
+ socket: socket as never,
+ store: store as never,
+ setIsConnected: vi.fn(),
+ });
+
+ socket.trigger('workflow_updated', {
+ workflow_id: 'wf-1',
+ user_id: 'owner-1',
+ old_is_public: true,
+ new_is_public: true,
+ });
+
+ expect(dispatch).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ nodeId: 'call-saved-workflows-node',
+ fieldName: 'workflow_id',
+ value: '',
+ }),
+ })
+ );
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.arrayContaining([{ type: 'Workflow', id: LIST_TAG }]),
+ })
+ );
+ });
});
From 61c82c957a7843de0a17aaad5215181e92a83bea Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 15:56:44 -0500
Subject: [PATCH 016/100] Validate selected saved workflows at runtime
---
.../app/invocations/call_saved_workflows.py | 16 ++
.../invocations/test_call_saved_workflows.py | 184 +++++++++++++++++-
2 files changed, 197 insertions(+), 3 deletions(-)
diff --git a/invokeai/app/invocations/call_saved_workflows.py b/invokeai/app/invocations/call_saved_workflows.py
index ce14b0d6787..0f34b93d662 100644
--- a/invokeai/app/invocations/call_saved_workflows.py
+++ b/invokeai/app/invocations/call_saved_workflows.py
@@ -2,6 +2,7 @@
from invokeai.app.invocations.fields import InputField
from invokeai.app.invocations.primitives import IntegerOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory, WorkflowNotFoundError
@invocation(
@@ -26,4 +27,19 @@ def invoke(self, context: InvocationContext) -> IntegerOutput:
if not self.workflow_id:
raise ValueError("A saved workflow must be selected before executing call_saved_workflows.")
+ try:
+ workflow_record = context._services.workflow_records.get(self.workflow_id)
+ except WorkflowNotFoundError as e:
+ raise ValueError(f"The selected saved workflow '{self.workflow_id}' could not be found.") from e
+
+ config = context._services.configuration
+ if config.multiuser:
+ queue_user_id = context._data.queue_item.user_id
+ user = context._services.users.get(queue_user_id)
+ is_admin = bool(user and user.is_admin)
+ is_owner = workflow_record.user_id == queue_user_id
+ is_default = workflow_record.workflow.meta.category is WorkflowCategory.Default
+ if not (is_default or is_owner or workflow_record.is_public or is_admin):
+ raise ValueError(f"The selected saved workflow '{self.workflow_id}' is not accessible to this user.")
+
return IntegerOutput(value=0)
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index 72a72787117..0faea70ef60 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -1,8 +1,90 @@
+from types import SimpleNamespace
from unittest.mock import Mock
import pytest
-from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.services.users.users_common import UserDTO
+from invokeai.app.services.workflow_records.workflow_records_common import (
+ Workflow,
+ WorkflowCategory,
+ WorkflowMeta,
+ WorkflowNotFoundError,
+ WorkflowRecordDTO,
+)
+
+
+def build_workflow_record_dto(
+ *,
+ workflow_id: str = "workflow-123",
+ user_id: str = "owner-1",
+ category: WorkflowCategory = WorkflowCategory.User,
+ is_public: bool = False,
+) -> WorkflowRecordDTO:
+ workflow = Workflow(
+ id=workflow_id,
+ name="Saved Workflow",
+ author="Tester",
+ description="",
+ version="1.0.0",
+ contact="",
+ tags="",
+ notes="",
+ exposedFields=[],
+ meta=WorkflowMeta(version="1.0.0", category=category),
+ nodes=[],
+ edges=[],
+ form=None,
+ )
+
+ return WorkflowRecordDTO(
+ workflow_id=workflow_id,
+ workflow=workflow,
+ name=workflow.name,
+ created_at="2026-04-08T00:00:00Z",
+ updated_at="2026-04-08T00:00:00Z",
+ opened_at=None,
+ user_id=user_id,
+ is_public=is_public,
+ )
+
+
+def build_user_dto(*, user_id: str = "user-1", is_admin: bool = False) -> UserDTO:
+ return UserDTO(
+ user_id=user_id,
+ email=f"{user_id}@example.test",
+ display_name=user_id,
+ is_admin=is_admin,
+ is_active=True,
+ created_at="2026-04-08T00:00:00Z",
+ updated_at="2026-04-08T00:00:00Z",
+ last_login_at=None,
+ )
+
+
+def build_context(
+ *,
+ workflow_record: WorkflowRecordDTO | None = None,
+ queue_user_id: str = "owner-1",
+ multiuser: bool = False,
+ user_is_admin: bool = False,
+ workflow_not_found: bool = False,
+):
+ services = SimpleNamespace(
+ configuration=SimpleNamespace(multiuser=multiuser),
+ users=Mock(),
+ workflow_records=Mock(),
+ )
+ services.users.get.return_value = build_user_dto(user_id=queue_user_id, is_admin=user_is_admin)
+
+ if workflow_not_found:
+ services.workflow_records.get.side_effect = WorkflowNotFoundError("missing")
+ else:
+ services.workflow_records.get.return_value = workflow_record or build_workflow_record_dto()
+
+ context = Mock()
+ context._services = services
+ context._data = SimpleNamespace(queue_item=SimpleNamespace(user_id=queue_user_id))
+ return context
def test_call_saved_workflows_invocation_contract():
@@ -14,7 +96,7 @@ def test_call_saved_workflows_invocation_contract():
assert invocation.get_type() == "call_saved_workflows"
assert invocation.workflow_id == "workflow-123"
- output = invocation.invoke(Mock(InvocationContext))
+ output = invocation.invoke(build_context())
assert isinstance(output, IntegerOutput)
assert output.value == 0
@@ -26,7 +108,103 @@ def test_call_saved_workflows_invocation_raises_when_workflow_id_is_empty():
invocation = CallSavedWorkflowsInvocation(id="test-node")
with pytest.raises(ValueError, match="saved workflow must be selected"):
- invocation.invoke(Mock(InvocationContext))
+ invocation.invoke(build_context())
+
+
+def test_call_saved_workflows_invocation_raises_when_workflow_does_not_exist():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+
+ invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="missing-workflow")
+
+ with pytest.raises(ValueError, match="could not be found"):
+ invocation.invoke(build_context(workflow_not_found=True))
+
+
+def test_call_saved_workflows_invocation_raises_when_workflow_is_not_accessible():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+
+ invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="private-workflow")
+
+ with pytest.raises(ValueError, match="is not accessible"):
+ invocation.invoke(
+ build_context(
+ workflow_record=build_workflow_record_dto(
+ workflow_id="private-workflow",
+ user_id="owner-1",
+ category=WorkflowCategory.User,
+ is_public=False,
+ ),
+ queue_user_id="other-user",
+ multiuser=True,
+ user_is_admin=False,
+ )
+ )
+
+
+def test_call_saved_workflows_invocation_allows_shared_workflow_for_non_owner():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+
+ invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="shared-workflow")
+
+ output = invocation.invoke(
+ build_context(
+ workflow_record=build_workflow_record_dto(
+ workflow_id="shared-workflow",
+ user_id="owner-1",
+ category=WorkflowCategory.User,
+ is_public=True,
+ ),
+ queue_user_id="other-user",
+ multiuser=True,
+ user_is_admin=False,
+ )
+ )
+
+ assert output.value == 0
+
+
+def test_call_saved_workflows_invocation_allows_default_workflow_for_non_owner():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+
+ invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="default-workflow")
+
+ output = invocation.invoke(
+ build_context(
+ workflow_record=build_workflow_record_dto(
+ workflow_id="default-workflow",
+ user_id="system",
+ category=WorkflowCategory.Default,
+ is_public=False,
+ ),
+ queue_user_id="other-user",
+ multiuser=True,
+ user_is_admin=False,
+ )
+ )
+
+ assert output.value == 0
+
+
+def test_call_saved_workflows_invocation_allows_admin_to_access_private_workflow():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+
+ invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="private-workflow")
+
+ output = invocation.invoke(
+ build_context(
+ workflow_record=build_workflow_record_dto(
+ workflow_id="private-workflow",
+ user_id="owner-1",
+ category=WorkflowCategory.User,
+ is_public=False,
+ ),
+ queue_user_id="admin-user",
+ multiuser=True,
+ user_is_admin=True,
+ )
+ )
+
+ assert output.value == 0
def test_call_saved_workflows_invocation_schema_hides_editor_managed_fields():
From e7496e65eca57b41879c5322c6b91197f60624c7 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 17:01:00 -0500
Subject: [PATCH 017/100] Generalize saved workflow picker into a field type
---
.../app/invocations/call_saved_workflows.py | 4 +-
invokeai/app/invocations/fields.py | 1 +
.../Invocation/CallSavedWorkflowsNode.tsx | 216 ------------------
.../Invocation/InvocationNodeWrapper.tsx | 9 +-
.../callSavedWorkflowsNodeUtils.test.ts | 76 ------
.../Invocation/callSavedWorkflowsNodeUtils.ts | 72 ------
.../Invocation/fields/InputFieldRenderer.tsx | 10 +
.../SavedWorkflowFieldInputComponent.tsx | 145 ++++++++++++
.../getInvocationNodeBodyComponent.test.ts | 13 --
.../getInvocationNodeBodyComponent.ts | 9 -
.../features/nodes/store/util/testUtils.ts | 13 +-
.../web/src/features/nodes/types/field.ts | 33 ++-
.../util/schema/buildFieldInputTemplate.ts | 24 ++
.../nodes/util/schema/parseSchema.test.ts | 2 +
.../invocations/test_call_saved_workflows.py | 4 +-
15 files changed, 229 insertions(+), 402 deletions(-)
delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.test.ts
delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.ts
diff --git a/invokeai/app/invocations/call_saved_workflows.py b/invokeai/app/invocations/call_saved_workflows.py
index 0f34b93d662..9442b486da8 100644
--- a/invokeai/app/invocations/call_saved_workflows.py
+++ b/invokeai/app/invocations/call_saved_workflows.py
@@ -1,5 +1,5 @@
from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
-from invokeai.app.invocations.fields import InputField
+from invokeai.app.invocations.fields import InputField, UIType
from invokeai.app.invocations.primitives import IntegerOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory, WorkflowNotFoundError
@@ -20,7 +20,7 @@ class CallSavedWorkflowsInvocation(BaseInvocation):
workflow_id: str = InputField(
default="",
description="The selected saved workflow ID, managed by the workflow editor UI.",
- ui_hidden=True,
+ ui_type=UIType.SavedWorkflow,
)
def invoke(self, context: InvocationContext) -> IntegerOutput:
diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py
index cca09a059d5..b7c302c0fed 100644
--- a/invokeai/app/invocations/fields.py
+++ b/invokeai/app/invocations/fields.py
@@ -49,6 +49,7 @@ class UIType(str, Enum, metaclass=MetaEnum):
# region Misc Field Types
Scheduler = "SchedulerField"
Any = "AnyField"
+ SavedWorkflow = "SavedWorkflowField"
# endregion
# region Internal Field Types
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
deleted file mode 100644
index b19892ddc5c..00000000000
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowsNode.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import type { ComboboxOnChange, ComboboxOption, SystemStyleObject } from '@invoke-ai/ui-library';
-import { Badge, Combobox, Flex, FormControl, Spinner, Text } from '@invoke-ai/ui-library';
-import { createSelector } from '@reduxjs/toolkit';
-import { EMPTY_ARRAY } from 'app/store/constants';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { IAINoContentFallback } from 'common/components/IAIImageFallback';
-import { fieldValueReset } from 'features/nodes/store/nodesSlice';
-import { selectNodesSlice } from 'features/nodes/store/selectors';
-import { NO_DRAG_CLASS, NO_FIT_ON_DOUBLE_CLICK_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
-import { memo, useCallback, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
-import type { S } from 'services/api/types';
-
-import {
- buildSavedWorkflowOptions,
- getSavedWorkflowSelectionState,
- getSelectedWorkflowOption,
-} from './callSavedWorkflowsNodeUtils';
-import InvocationNodeHeader from './InvocationNodeHeader';
-
-const MISSING_SELECTION_LABEL = 'Missing or inaccessible workflow';
-
-type Props = {
- nodeId: string;
- isOpen: boolean;
-};
-
-const bodySx: SystemStyleObject = {
- flexDirection: 'column',
- w: 'full',
- h: 'full',
- py: 2,
- gap: 2,
- borderBottomRadius: 'base',
- '&[data-is-open="false"]': {
- display: 'none',
- },
-};
-
-const queryArg = {
- page: 0,
- per_page: 50,
- order_by: 'name',
- direction: 'ASC',
- categories: ['user', 'default'],
- query: '',
- tags: [],
- has_been_opened: undefined,
- is_public: undefined,
-} satisfies Parameters[0];
-
-const queryOptions = {
- selectFromResult: ({ data, ...rest }) => ({
- items: data?.pages.flatMap(({ items }) => items) ?? EMPTY_ARRAY,
- ...rest,
- }),
-} satisfies Parameters[1];
-
-const CallSavedWorkflowsNode = ({ nodeId, isOpen }: Props) => {
- const dispatch = useAppDispatch();
- const { items, isLoading, isFetching } = useListWorkflowsInfiniteInfiniteQuery(queryArg, queryOptions);
- const selectWorkflowId = useMemo(
- () =>
- createSelector(selectNodesSlice, (nodes) => {
- const node = nodes.nodes.find((node) => node.id === nodeId);
- if (node?.type !== 'invocation') {
- return '';
- }
-
- const workflowId = node.data.inputs.workflow_id?.value;
- return typeof workflowId === 'string' ? workflowId : '';
- }),
- [nodeId]
- );
- const workflowId = useAppSelector(selectWorkflowId);
- const options = useMemo(() => buildSavedWorkflowOptions(items), [items]);
- const selectionState = useMemo(() => getSavedWorkflowSelectionState(items, workflowId), [items, workflowId]);
- const selectedOption = useMemo(
- () => getSelectedWorkflowOption(items, workflowId, MISSING_SELECTION_LABEL),
- [items, workflowId]
- );
-
- const onChange = useCallback(
- (value) => {
- dispatch(
- fieldValueReset({
- nodeId,
- fieldName: 'workflow_id',
- value: value?.value ?? '',
- })
- );
- },
- [dispatch, nodeId]
- );
-
- return (
- <>
-
-
-
-
-
- Saved Workflows
-
- {items.length}
-
- {isLoading ? (
-
- ) : (
-
- )}
-
-
- >
- );
-};
-
-export default memo(CallSavedWorkflowsNode);
-
-const LoadingState = memo(() => {
- return (
-
-
-
- );
-});
-LoadingState.displayName = 'LoadingState';
-
-const WorkflowPicker = memo(
- ({
- options,
- selectionState,
- selectedOption,
- isFetching,
- onChange,
- }: {
- options: ComboboxOption[];
- selectionState:
- | { status: 'unselected' }
- | { status: 'selected'; workflow: S['WorkflowRecordListItemWithThumbnailDTO'] }
- | { status: 'missing'; workflowId: string };
- selectedOption: ComboboxOption | null;
- isFetching: boolean;
- onChange: ComboboxOnChange;
- }) => {
- const { t } = useTranslation();
- const noOptionsMessage = useCallback(() => t('nodes.noMatchingWorkflows'), [t]);
-
- if (options.length === 0) {
- return ;
- }
-
- return (
-
-
-
-
- {selectionState.status === 'selected' ? (
-
-
- {selectionState.workflow.name}
-
- {selectionState.workflow.category === 'default' && Default}
- {selectionState.workflow.is_public && selectionState.workflow.category !== 'default' && (
- Shared
- )}
-
- ) : (
-
- )}
- {isFetching && (
-
- Updating...
-
- )}
-
- );
- }
-);
-WorkflowPicker.displayName = 'WorkflowPicker';
-
-const SelectionStatusBadge = memo(
- ({
- selectionState,
- }: {
- selectionState:
- | { status: 'unselected' }
- | { status: 'selected'; workflow: S['WorkflowRecordListItemWithThumbnailDTO'] }
- | { status: 'missing'; workflowId: string };
- }) => {
- if (selectionState.status === 'selected') {
- return null;
- }
-
- return (
-
- {selectionState.status === 'missing' ? MISSING_SELECTION_LABEL : 'Choose a workflow'}
-
- );
- }
-);
-SelectionStatusBadge.displayName = 'SelectionStatusBadge';
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
index 84bfb2179eb..7a4ea9ca65c 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
@@ -9,9 +9,7 @@ import { selectNodes } from 'features/nodes/store/selectors';
import type { InvocationNodeData } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
-import CallSavedWorkflowsNode from './CallSavedWorkflowsNode';
import { InvocationNodeContextProvider } from './context';
-import { getInvocationNodeBodyComponentKey } from './getInvocationNodeBodyComponent';
import InvocationNodeUnknownFallback from './InvocationNodeUnknownFallback';
const InvocationNodeWrapper = (props: NodeProps>) => {
@@ -19,7 +17,6 @@ const InvocationNodeWrapper = (props: NodeProps>) => {
const { id: nodeId, type, isOpen, label } = data;
const templates = useStore($templates);
const hasTemplate = useMemo(() => Boolean(templates[type]), [templates, type]);
- const bodyComponentKey = useMemo(() => getInvocationNodeBodyComponentKey(type), [type]);
const selectNodeExists = useMemo(
() => createSelector(selectNodes, (nodes) => Boolean(nodes.find((n) => n.id === nodeId))),
[nodeId]
@@ -43,11 +40,7 @@ const InvocationNodeWrapper = (props: NodeProps>) => {
return (
- {bodyComponentKey === 'call_saved_workflows' ? (
-
- ) : (
-
- )}
+
);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
deleted file mode 100644
index d1d0d426e7a..00000000000
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.test.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
-import { describe, expect, it } from 'vitest';
-
-import {
- buildSavedWorkflowOptions,
- getSavedWorkflowSelectionState,
- getSelectedWorkflow,
- getSelectedWorkflowOption,
- MISSING_WORKFLOW_OPTION_VALUE,
-} from './callSavedWorkflowsNodeUtils';
-
-const workflows: WorkflowRecordListItemWithThumbnailDTO[] = [
- {
- workflow_id: 'workflow-a',
- name: 'Alpha Workflow',
- created_at: '',
- updated_at: '',
- opened_at: null,
- description: '',
- tags: '',
- is_public: false,
- thumbnail_url: null,
- category: 'user',
- user_id: 'user-a',
- },
- {
- workflow_id: 'workflow-b',
- name: 'Beta Workflow',
- created_at: '',
- updated_at: '',
- opened_at: null,
- description: '',
- tags: '',
- is_public: true,
- thumbnail_url: null,
- category: 'default',
- user_id: 'system',
- },
-];
-
-describe('callSavedWorkflowsNodeUtils', () => {
- it('builds combobox options from visible workflows', () => {
- expect(buildSavedWorkflowOptions(workflows)).toEqual([
- { label: 'Alpha Workflow', value: 'workflow-a' },
- { label: 'Beta Workflow', value: 'workflow-b' },
- ]);
- });
-
- it('resolves the selected workflow for a stored workflow id', () => {
- expect(getSelectedWorkflow(workflows, 'workflow-b')).toEqual(workflows[1]);
- });
-
- it('returns null when no workflow is selected', () => {
- expect(getSavedWorkflowSelectionState(workflows, '')).toEqual({ status: 'unselected' });
- expect(getSelectedWorkflow(workflows, '')).toBeNull();
- expect(getSelectedWorkflowOption(workflows, '', 'Missing or inaccessible workflow')).toBeNull();
- });
-
- it('returns a selected state when the workflow id resolves', () => {
- expect(getSavedWorkflowSelectionState(workflows, 'workflow-a')).toEqual({
- status: 'selected',
- workflow: workflows[0],
- });
- });
-
- it('returns a synthetic missing option when the stored workflow id is stale', () => {
- expect(getSavedWorkflowSelectionState(workflows, 'missing-workflow')).toEqual({
- status: 'missing',
- workflowId: 'missing-workflow',
- });
- expect(getSelectedWorkflowOption(workflows, 'missing-workflow', 'Missing or inaccessible workflow')).toEqual({
- label: 'Missing or inaccessible workflow',
- value: MISSING_WORKFLOW_OPTION_VALUE,
- });
- });
-});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.ts
deleted file mode 100644
index 43e2b1a7c11..00000000000
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowsNodeUtils.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import type { ComboboxOption } from '@invoke-ai/ui-library';
-import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
-
-export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
-
-export type SavedWorkflowSelectionState =
- | { status: 'unselected' }
- | { status: 'selected'; workflow: WorkflowRecordListItemWithThumbnailDTO }
- | { status: 'missing'; workflowId: string };
-
-export const buildSavedWorkflowOptions = (
- workflows: WorkflowRecordListItemWithThumbnailDTO[]
-): ComboboxOption[] => {
- return workflows.map((workflow) => ({
- label: workflow.name,
- value: workflow.workflow_id,
- }));
-};
-
-export const getSelectedWorkflow = (
- workflows: WorkflowRecordListItemWithThumbnailDTO[],
- workflowId: string
-): WorkflowRecordListItemWithThumbnailDTO | null => {
- const selectionState = getSavedWorkflowSelectionState(workflows, workflowId);
-
- if (selectionState.status !== 'selected') {
- return null;
- }
-
- return selectionState.workflow;
-};
-
-export const getSavedWorkflowSelectionState = (
- workflows: WorkflowRecordListItemWithThumbnailDTO[],
- workflowId: string
-): SavedWorkflowSelectionState => {
- if (!workflowId) {
- return { status: 'unselected' };
- }
-
- const workflow = workflows.find((workflow) => workflow.workflow_id === workflowId);
-
- if (workflow) {
- return { status: 'selected', workflow };
- }
-
- return { status: 'missing', workflowId };
-};
-
-export const getSelectedWorkflowOption = (
- workflows: WorkflowRecordListItemWithThumbnailDTO[],
- workflowId: string,
- missingLabel: string
-): ComboboxOption | null => {
- const selectionState = getSavedWorkflowSelectionState(workflows, workflowId);
-
- if (selectionState.status === 'unselected') {
- return null;
- }
-
- if (selectionState.status === 'selected') {
- return {
- label: selectionState.workflow.name,
- value: selectionState.workflow.workflow_id,
- };
- }
-
- return {
- label: missingLabel,
- value: MISSING_WORKFLOW_OPTION_VALUE,
- };
-};
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx
index 60a3f8e472a..4fa020212ee 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx
@@ -8,6 +8,7 @@ import { ImageGeneratorFieldInputComponent } from 'features/nodes/components/flo
import { IntegerFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerFieldCollectionInputComponent';
import { IntegerGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerGeneratorFieldComponent';
import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelIdentifierFieldInputComponent';
+import SavedWorkflowFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent';
import { StringFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringFieldCollectionInputComponent';
import { StringGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringGeneratorFieldComponent';
import { IntegerFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput';
@@ -47,6 +48,8 @@ import {
isIntegerGeneratorFieldInputTemplate,
isModelIdentifierFieldInputInstance,
isModelIdentifierFieldInputTemplate,
+ isSavedWorkflowFieldInputInstance,
+ isSavedWorkflowFieldInputTemplate,
isSchedulerFieldInputInstance,
isSchedulerFieldInputTemplate,
isStringFieldCollectionInputInstance,
@@ -223,6 +226,13 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
return ;
}
+ if (isSavedWorkflowFieldInputTemplate(template)) {
+ if (!isSavedWorkflowFieldInputInstance(field)) {
+ return null;
+ }
+ return ;
+ }
+
if (isColorFieldInputTemplate(template)) {
if (!isColorFieldInputInstance(field)) {
return null;
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
new file mode 100644
index 00000000000..10937a28da2
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
@@ -0,0 +1,145 @@
+import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
+import { Badge, Combobox, Flex, Text } from '@invoke-ai/ui-library';
+import { EMPTY_ARRAY } from 'app/store/constants';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { fieldStringValueChanged } from 'features/nodes/store/nodesSlice';
+import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
+import type { SavedWorkflowFieldInputInstance, SavedWorkflowFieldInputTemplate } from 'features/nodes/types/field';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
+import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
+
+import type { FieldComponentProps } from './types';
+
+const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
+const MISSING_SELECTION_LABEL = 'Missing or inaccessible workflow';
+
+const queryArg = {
+ page: 0,
+ per_page: 50,
+ order_by: 'name',
+ direction: 'ASC',
+ categories: ['user', 'default'],
+ query: '',
+ tags: [],
+ has_been_opened: undefined,
+ is_public: undefined,
+} satisfies Parameters[0];
+
+const queryOptions = {
+ selectFromResult: ({ data, ...rest }) => ({
+ items: data?.pages.flatMap(({ items }) => items) ?? EMPTY_ARRAY,
+ ...rest,
+ }),
+} satisfies Parameters[1];
+
+type SelectionState =
+ | { status: 'unselected' }
+ | { status: 'selected'; workflow: WorkflowRecordListItemWithThumbnailDTO }
+ | { status: 'missing'; workflowId: string };
+
+const getSelectionState = (
+ workflows: WorkflowRecordListItemWithThumbnailDTO[],
+ workflowId: string
+): SelectionState => {
+ if (!workflowId) {
+ return { status: 'unselected' };
+ }
+
+ const workflow = workflows.find((workflow) => workflow.workflow_id === workflowId);
+ if (workflow) {
+ return { status: 'selected', workflow };
+ }
+
+ return { status: 'missing', workflowId };
+};
+
+const SavedWorkflowFieldInputComponent = (
+ props: FieldComponentProps
+) => {
+ const { nodeId, field } = props;
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+ const { items, isLoading, isFetching } = useListWorkflowsInfiniteInfiniteQuery(queryArg, queryOptions);
+
+ const options = useMemo(
+ () =>
+ items.map((workflow) => ({
+ label: workflow.name,
+ value: workflow.workflow_id,
+ })),
+ [items]
+ );
+
+ const selectionState = useMemo(() => getSelectionState(items, field.value), [field.value, items]);
+
+ const value = useMemo(() => {
+ if (selectionState.status === 'unselected') {
+ return null;
+ }
+
+ if (selectionState.status === 'selected') {
+ return {
+ label: selectionState.workflow.name,
+ value: selectionState.workflow.workflow_id,
+ };
+ }
+
+ return {
+ label: MISSING_SELECTION_LABEL,
+ value: MISSING_WORKFLOW_OPTION_VALUE,
+ };
+ }, [selectionState]);
+
+ const onChange = useCallback(
+ (v) => {
+ dispatch(
+ fieldStringValueChanged({
+ nodeId,
+ fieldName: field.name,
+ value: v?.value ?? '',
+ })
+ );
+ },
+ [dispatch, field.name, nodeId]
+ );
+
+ const noOptionsMessage = useCallback(() => t('nodes.noMatchingWorkflows'), [t]);
+
+ return (
+
+
+ {selectionState.status === 'selected' ? (
+
+
+ {selectionState.workflow.name}
+
+ {selectionState.workflow.category === 'default' && Default}
+ {selectionState.workflow.is_public && selectionState.workflow.category !== 'default' && (
+ Shared
+ )}
+
+ ) : (
+
+ {selectionState.status === 'missing' ? MISSING_SELECTION_LABEL : 'Choose a workflow'}
+
+ )}
+ {isFetching && (
+
+ Updating...
+
+ )}
+
+ );
+};
+
+export default memo(SavedWorkflowFieldInputComponent);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.test.ts
deleted file mode 100644
index bce95f52d91..00000000000
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.test.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { getInvocationNodeBodyComponentKey } from './getInvocationNodeBodyComponent';
-
-describe('getInvocationNodeBodyComponentKey', () => {
- it('returns the specialized renderer for call_saved_workflows nodes', () => {
- expect(getInvocationNodeBodyComponentKey('call_saved_workflows')).toBe('call_saved_workflows');
- });
-
- it('falls back to the default renderer for other invocation nodes', () => {
- expect(getInvocationNodeBodyComponentKey('add')).toBe('default');
- });
-});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.ts
deleted file mode 100644
index 355cc6630d3..00000000000
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/getInvocationNodeBodyComponent.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export type InvocationNodeBodyComponentKey = 'default' | 'call_saved_workflows';
-
-export const getInvocationNodeBodyComponentKey = (type: string): InvocationNodeBodyComponentKey => {
- if (type === 'call_saved_workflows') {
- return 'call_saved_workflows';
- }
-
- return 'default';
-};
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
index f40a29f9558..f6eef13f941 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
@@ -87,11 +87,17 @@ export const call_saved_workflows: InvocationTemplate = {
description: 'The selected saved workflow ID, managed by the workflow editor UI.',
fieldKind: 'input',
input: 'any',
- ui_hidden: true,
+ ui_hidden: false,
+ ui_type: 'SavedWorkflowField',
type: {
- name: 'StringField',
+ name: 'SavedWorkflowField',
cardinality: 'SINGLE',
batch: false,
+ originalType: {
+ name: 'StringField',
+ cardinality: 'SINGLE',
+ batch: false,
+ },
},
default: '',
},
@@ -623,7 +629,8 @@ export const schema = {
input: 'any',
orig_default: '',
orig_required: false,
- ui_hidden: true,
+ ui_hidden: false,
+ ui_type: 'SavedWorkflowField',
},
type: {
type: 'string',
diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts
index 98b20912ab2..aff8fa097b4 100644
--- a/invokeai/frontend/web/src/features/nodes/types/field.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/field.ts
@@ -187,6 +187,10 @@ const zSchedulerFieldType = zFieldTypeBase.extend({
name: z.literal('SchedulerField'),
originalType: zStatelessFieldType.optional(),
});
+const zSavedWorkflowFieldType = zFieldTypeBase.extend({
+ name: z.literal('SavedWorkflowField'),
+ originalType: zStatelessFieldType.optional(),
+});
const zFloatGeneratorFieldType = zFieldTypeBase.extend({
name: z.literal('FloatGeneratorField'),
originalType: zStatelessFieldType.optional(),
@@ -215,6 +219,7 @@ const zStatefulFieldType = z.union([
zModelIdentifierFieldType,
zColorFieldType,
zSchedulerFieldType,
+ zSavedWorkflowFieldType,
zFloatGeneratorFieldType,
zIntegerGeneratorFieldType,
zStringGeneratorFieldType,
@@ -696,6 +701,29 @@ export const isSchedulerFieldInputInstance = buildInstanceTypeGuard(zSchedulerFi
export const isSchedulerFieldInputTemplate = buildTemplateTypeGuard('SchedulerField');
// #endregion
+// #region SavedWorkflowField
+export const zSavedWorkflowFieldValue = z.string();
+const zSavedWorkflowFieldInputInstance = zFieldInputInstanceBase.extend({
+ value: zSavedWorkflowFieldValue,
+});
+const zSavedWorkflowFieldInputTemplate = zFieldInputTemplateBase.extend({
+ type: zSavedWorkflowFieldType,
+ originalType: zFieldType.optional(),
+ default: zSavedWorkflowFieldValue,
+ maxLength: z.number().int().gte(0).optional(),
+ minLength: z.number().int().gte(0).optional(),
+});
+const zSavedWorkflowFieldOutputTemplate = zFieldOutputTemplateBase.extend({
+ type: zSavedWorkflowFieldType,
+});
+export type SavedWorkflowFieldValue = z.infer;
+export type SavedWorkflowFieldInputInstance = z.infer;
+export type SavedWorkflowFieldInputTemplate = z.infer;
+export const isSavedWorkflowFieldInputInstance = buildInstanceTypeGuard(zSavedWorkflowFieldInputInstance);
+export const isSavedWorkflowFieldInputTemplate =
+ buildTemplateTypeGuard('SavedWorkflowField');
+// #endregion
+
// #region FloatGeneratorField
export const FloatGeneratorArithmeticSequenceType = 'float_generator_arithmetic_sequence';
const zFloatGeneratorArithmeticSequence = z.object({
@@ -1289,6 +1317,7 @@ export const zStatefulFieldValue = z.union([
zModelIdentifierFieldValue,
zColorFieldValue,
zSchedulerFieldValue,
+ zSavedWorkflowFieldValue,
zFloatGeneratorFieldValue,
zIntegerGeneratorFieldValue,
zStringGeneratorFieldValue,
@@ -1317,6 +1346,7 @@ const zStatefulFieldInputInstance = z.union([
zModelIdentifierFieldInputInstance,
zColorFieldInputInstance,
zSchedulerFieldInputInstance,
+ zSavedWorkflowFieldInputInstance,
zFloatGeneratorFieldInputInstance,
zIntegerGeneratorFieldInputInstance,
zStringGeneratorFieldInputInstance,
@@ -1344,7 +1374,7 @@ const zStatefulFieldInputTemplate = z.union([
zModelIdentifierFieldInputTemplate,
zColorFieldInputTemplate,
zSchedulerFieldInputTemplate,
- zStatelessFieldInputTemplate,
+ zSavedWorkflowFieldInputTemplate,
zFloatGeneratorFieldInputTemplate,
zIntegerGeneratorFieldInputTemplate,
zStringGeneratorFieldInputTemplate,
@@ -1372,6 +1402,7 @@ const zStatefulFieldOutputTemplate = z.union([
zModelIdentifierFieldOutputTemplate,
zColorFieldOutputTemplate,
zSchedulerFieldOutputTemplate,
+ zSavedWorkflowFieldOutputTemplate,
zFloatGeneratorFieldOutputTemplate,
zIntegerGeneratorFieldOutputTemplate,
zStringGeneratorFieldOutputTemplate,
diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts
index 27a0b21a7c9..adc256f46d0 100644
--- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts
@@ -17,6 +17,7 @@ import type {
IntegerFieldInputTemplate,
IntegerGeneratorFieldInputTemplate,
ModelIdentifierFieldInputTemplate,
+ SavedWorkflowFieldInputTemplate,
SchedulerFieldInputTemplate,
StatefulFieldType,
StatelessFieldInputTemplate,
@@ -408,6 +409,28 @@ const buildSchedulerFieldInputTemplate: FieldInputTemplateBuilder = ({
+ schemaObject,
+ baseField,
+ fieldType,
+}) => {
+ const template: SavedWorkflowFieldInputTemplate = {
+ ...baseField,
+ type: fieldType,
+ default: schemaObject.default ?? '',
+ };
+
+ if (schemaObject.minLength !== undefined) {
+ template.minLength = schemaObject.minLength;
+ }
+
+ if (schemaObject.maxLength !== undefined) {
+ template.maxLength = schemaObject.maxLength;
+ }
+
+ return template;
+};
+
const buildFloatGeneratorFieldInputTemplate: FieldInputTemplateBuilder = ({
// schemaObject,
baseField,
@@ -474,6 +497,7 @@ const TEMPLATE_BUILDER_MAP: Record {
it('should parse the call_saved_workflows node template', () => {
const parsed = parseSchema(schema);
expect(stripUndefinedDeep(parsed.call_saved_workflows)).toEqual(stripUndefinedDeep(call_saved_workflows));
+ expect(parsed.call_saved_workflows.inputs.workflow_id.type.name).toBe('SavedWorkflowField');
+ expect(parsed.call_saved_workflows.inputs.workflow_id.ui_type).toBe('SavedWorkflowField');
});
});
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index 0faea70ef60..e7da11e0e1a 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -207,12 +207,12 @@ def test_call_saved_workflows_invocation_allows_admin_to_access_private_workflow
assert output.value == 0
-def test_call_saved_workflows_invocation_schema_hides_editor_managed_fields():
+def test_call_saved_workflows_invocation_schema_declares_saved_workflow_ui_type():
from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
schema = CallSavedWorkflowsInvocation.model_json_schema()
workflow_id = schema["properties"]["workflow_id"]
assert workflow_id["default"] == ""
- assert workflow_id["ui_hidden"] is True
assert workflow_id["input"] == "any"
+ assert workflow_id["ui_type"] == "SavedWorkflowField"
From ac0ca608c51bafb97f397b490bbf2472b5dacabd Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 17:06:19 -0500
Subject: [PATCH 018/100] Harden saved workflow field behavior
---
.../SavedWorkflowFieldInputComponent.tsx | 68 +++-------------
.../inputs/savedWorkflowFieldUtils.test.ts | 77 +++++++++++++++++++
.../fields/inputs/savedWorkflowFieldUtils.ts | 64 +++++++++++++++
.../services/events/setEventListeners.test.ts | 47 +++++++++++
.../invocations/test_call_saved_workflows.py | 24 +++++-
5 files changed, 223 insertions(+), 57 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
index 10937a28da2..1dcc978f643 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
@@ -8,13 +8,16 @@ import type { SavedWorkflowFieldInputInstance, SavedWorkflowFieldInputTemplate }
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
-import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
+import {
+ buildSavedWorkflowOptions,
+ EMPTY_SELECTION_LABEL,
+ getSavedWorkflowSelectionOption,
+ getSavedWorkflowSelectionState,
+ getSavedWorkflowSelectionStatusLabel,
+} from './savedWorkflowFieldUtils';
import type { FieldComponentProps } from './types';
-const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
-const MISSING_SELECTION_LABEL = 'Missing or inaccessible workflow';
-
const queryArg = {
page: 0,
per_page: 50,
@@ -34,27 +37,6 @@ const queryOptions = {
}),
} satisfies Parameters[1];
-type SelectionState =
- | { status: 'unselected' }
- | { status: 'selected'; workflow: WorkflowRecordListItemWithThumbnailDTO }
- | { status: 'missing'; workflowId: string };
-
-const getSelectionState = (
- workflows: WorkflowRecordListItemWithThumbnailDTO[],
- workflowId: string
-): SelectionState => {
- if (!workflowId) {
- return { status: 'unselected' };
- }
-
- const workflow = workflows.find((workflow) => workflow.workflow_id === workflowId);
- if (workflow) {
- return { status: 'selected', workflow };
- }
-
- return { status: 'missing', workflowId };
-};
-
const SavedWorkflowFieldInputComponent = (
props: FieldComponentProps
) => {
@@ -63,34 +45,10 @@ const SavedWorkflowFieldInputComponent = (
const { t } = useTranslation();
const { items, isLoading, isFetching } = useListWorkflowsInfiniteInfiniteQuery(queryArg, queryOptions);
- const options = useMemo(
- () =>
- items.map((workflow) => ({
- label: workflow.name,
- value: workflow.workflow_id,
- })),
- [items]
- );
-
- const selectionState = useMemo(() => getSelectionState(items, field.value), [field.value, items]);
-
- const value = useMemo(() => {
- if (selectionState.status === 'unselected') {
- return null;
- }
-
- if (selectionState.status === 'selected') {
- return {
- label: selectionState.workflow.name,
- value: selectionState.workflow.workflow_id,
- };
- }
-
- return {
- label: MISSING_SELECTION_LABEL,
- value: MISSING_WORKFLOW_OPTION_VALUE,
- };
- }, [selectionState]);
+ const options = useMemo(() => buildSavedWorkflowOptions(items), [items]);
+ const selectionState = useMemo(() => getSavedWorkflowSelectionState(items, field.value), [field.value, items]);
+ const value = useMemo(() => getSavedWorkflowSelectionOption(selectionState), [selectionState]);
+ const statusLabel = useMemo(() => getSavedWorkflowSelectionStatusLabel(selectionState), [selectionState]);
const onChange = useCallback(
(v) => {
@@ -129,9 +87,7 @@ const SavedWorkflowFieldInputComponent = (
)}
) : (
-
- {selectionState.status === 'missing' ? MISSING_SELECTION_LABEL : 'Choose a workflow'}
-
+ {statusLabel ?? EMPTY_SELECTION_LABEL}
)}
{isFetching && (
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
new file mode 100644
index 00000000000..e2bb33fc0cc
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
@@ -0,0 +1,77 @@
+import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildSavedWorkflowOptions,
+ EMPTY_SELECTION_LABEL,
+ getSavedWorkflowSelectionOption,
+ getSavedWorkflowSelectionState,
+ getSavedWorkflowSelectionStatusLabel,
+ MISSING_SELECTION_LABEL,
+ MISSING_WORKFLOW_OPTION_VALUE,
+} from './savedWorkflowFieldUtils';
+
+const workflows: WorkflowRecordListItemWithThumbnailDTO[] = [
+ {
+ workflow_id: 'workflow-a',
+ name: 'Alpha Workflow',
+ created_at: '',
+ updated_at: '',
+ opened_at: null,
+ description: '',
+ tags: '',
+ is_public: false,
+ thumbnail_url: null,
+ category: 'user',
+ user_id: 'user-a',
+ },
+ {
+ workflow_id: 'workflow-b',
+ name: 'Beta Workflow',
+ created_at: '',
+ updated_at: '',
+ opened_at: null,
+ description: '',
+ tags: '',
+ is_public: true,
+ thumbnail_url: null,
+ category: 'default',
+ user_id: 'system',
+ },
+];
+
+describe('savedWorkflowFieldUtils', () => {
+ it('builds combobox options from visible workflows', () => {
+ expect(buildSavedWorkflowOptions(workflows)).toEqual([
+ { label: 'Alpha Workflow', value: 'workflow-a' },
+ { label: 'Beta Workflow', value: 'workflow-b' },
+ ]);
+ });
+
+ it('returns an unselected state for the default empty value', () => {
+ const selectionState = getSavedWorkflowSelectionState(workflows, '');
+ expect(selectionState).toEqual({ status: 'unselected' });
+ expect(getSavedWorkflowSelectionOption(selectionState)).toBeNull();
+ expect(getSavedWorkflowSelectionStatusLabel(selectionState)).toBe(EMPTY_SELECTION_LABEL);
+ });
+
+ it('returns a selected state for a valid workflow id', () => {
+ const selectionState = getSavedWorkflowSelectionState(workflows, 'workflow-b');
+ expect(selectionState).toEqual({ status: 'selected', workflow: workflows[1] });
+ expect(getSavedWorkflowSelectionOption(selectionState)).toEqual({
+ label: 'Beta Workflow',
+ value: 'workflow-b',
+ });
+ expect(getSavedWorkflowSelectionStatusLabel(selectionState)).toBeNull();
+ });
+
+ it('returns a missing state for a stale or inaccessible workflow id', () => {
+ const selectionState = getSavedWorkflowSelectionState(workflows, 'missing-workflow');
+ expect(selectionState).toEqual({ status: 'missing', workflowId: 'missing-workflow' });
+ expect(getSavedWorkflowSelectionOption(selectionState)).toEqual({
+ label: MISSING_SELECTION_LABEL,
+ value: MISSING_WORKFLOW_OPTION_VALUE,
+ });
+ expect(getSavedWorkflowSelectionStatusLabel(selectionState)).toBe(MISSING_SELECTION_LABEL);
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
new file mode 100644
index 00000000000..f588a41a0df
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -0,0 +1,64 @@
+import type { ComboboxOption } from '@invoke-ai/ui-library';
+import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
+
+export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
+export const MISSING_SELECTION_LABEL = 'Missing or inaccessible workflow';
+export const EMPTY_SELECTION_LABEL = 'Choose a workflow';
+
+export type SavedWorkflowSelectionState =
+ | { status: 'unselected' }
+ | { status: 'selected'; workflow: WorkflowRecordListItemWithThumbnailDTO }
+ | { status: 'missing'; workflowId: string };
+
+export const buildSavedWorkflowOptions = (
+ workflows: WorkflowRecordListItemWithThumbnailDTO[]
+): ComboboxOption[] => {
+ return workflows.map((workflow) => ({
+ label: workflow.name,
+ value: workflow.workflow_id,
+ }));
+};
+
+export const getSavedWorkflowSelectionState = (
+ workflows: WorkflowRecordListItemWithThumbnailDTO[],
+ workflowId: string
+): SavedWorkflowSelectionState => {
+ if (!workflowId) {
+ return { status: 'unselected' };
+ }
+
+ const workflow = workflows.find((workflow) => workflow.workflow_id === workflowId);
+ if (workflow) {
+ return { status: 'selected', workflow };
+ }
+
+ return { status: 'missing', workflowId };
+};
+
+export const getSavedWorkflowSelectionOption = (
+ selectionState: SavedWorkflowSelectionState
+): ComboboxOption | null => {
+ if (selectionState.status === 'unselected') {
+ return null;
+ }
+
+ if (selectionState.status === 'selected') {
+ return {
+ label: selectionState.workflow.name,
+ value: selectionState.workflow.workflow_id,
+ };
+ }
+
+ return {
+ label: MISSING_SELECTION_LABEL,
+ value: MISSING_WORKFLOW_OPTION_VALUE,
+ };
+};
+
+export const getSavedWorkflowSelectionStatusLabel = (selectionState: SavedWorkflowSelectionState): string | null => {
+ if (selectionState.status === 'selected') {
+ return null;
+ }
+
+ return selectionState.status === 'missing' ? MISSING_SELECTION_LABEL : EMPTY_SELECTION_LABEL;
+};
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
index 35b6f568bb7..fdb59cf95c9 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
@@ -200,4 +200,51 @@ describe('setEventListeners workflow live updates', () => {
})
);
});
+
+ it('clears selected workflow ids when access is lost via a workflow_deleted event', () => {
+ const socket = createMockSocket();
+ const dispatch = vi.fn();
+ const store = {
+ dispatch,
+ getState: vi.fn(() => ({
+ nodes: {
+ present: {
+ nodes: [
+ {
+ id: 'call-saved-workflows-node',
+ type: 'invocation',
+ data: {
+ id: 'call-saved-workflows-node',
+ type: 'call_saved_workflows',
+ inputs: {
+ workflow_id: {
+ value: 'wf-shared',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ })),
+ };
+
+ setEventListeners({
+ socket: socket as never,
+ store: store as never,
+ setIsConnected: vi.fn(),
+ });
+
+ socket.trigger('workflow_deleted', { workflow_id: 'wf-shared' });
+
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ nodeId: 'call-saved-workflows-node',
+ fieldName: 'workflow_id',
+ value: '',
+ }),
+ })
+ );
+ });
});
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index e7da11e0e1a..fc61f60f00b 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -67,6 +67,7 @@ def build_context(
queue_user_id: str = "owner-1",
multiuser: bool = False,
user_is_admin: bool = False,
+ user_exists: bool = True,
workflow_not_found: bool = False,
):
services = SimpleNamespace(
@@ -74,7 +75,7 @@ def build_context(
users=Mock(),
workflow_records=Mock(),
)
- services.users.get.return_value = build_user_dto(user_id=queue_user_id, is_admin=user_is_admin)
+ services.users.get.return_value = build_user_dto(user_id=queue_user_id, is_admin=user_is_admin) if user_exists else None
if workflow_not_found:
services.workflow_records.get.side_effect = WorkflowNotFoundError("missing")
@@ -207,6 +208,27 @@ def test_call_saved_workflows_invocation_allows_admin_to_access_private_workflow
assert output.value == 0
+def test_call_saved_workflows_invocation_raises_when_private_workflow_user_record_is_missing():
+ from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+
+ invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="private-workflow")
+
+ with pytest.raises(ValueError, match="is not accessible"):
+ invocation.invoke(
+ build_context(
+ workflow_record=build_workflow_record_dto(
+ workflow_id="private-workflow",
+ user_id="owner-1",
+ category=WorkflowCategory.User,
+ is_public=False,
+ ),
+ queue_user_id="other-user",
+ multiuser=True,
+ user_exists=False,
+ )
+ )
+
+
def test_call_saved_workflows_invocation_schema_declares_saved_workflow_ui_type():
from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
From b2a2007f1b9d9320da4a4deb107cac572b79445f Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 17:34:19 -0500
Subject: [PATCH 019/100] Rename saved workflow node to singular
---
...ed_workflows.py => call_saved_workflow.py} | 10 ++--
.../features/nodes/store/util/testUtils.ts | 22 +++----
.../nodes/util/schema/parseSchema.test.ts | 10 ++--
.../nodes/util/workflow/buildWorkflow.test.ts | 8 +--
.../services/events/setEventListeners.test.ts | 10 ++--
.../src/services/events/setEventListeners.tsx | 2 +-
.../invocations/test_call_saved_workflows.py | 60 ++++++++++---------
7 files changed, 62 insertions(+), 60 deletions(-)
rename invokeai/app/invocations/{call_saved_workflows.py => call_saved_workflow.py} (88%)
diff --git a/invokeai/app/invocations/call_saved_workflows.py b/invokeai/app/invocations/call_saved_workflow.py
similarity index 88%
rename from invokeai/app/invocations/call_saved_workflows.py
rename to invokeai/app/invocations/call_saved_workflow.py
index 9442b486da8..1768caf5c9c 100644
--- a/invokeai/app/invocations/call_saved_workflows.py
+++ b/invokeai/app/invocations/call_saved_workflow.py
@@ -6,16 +6,16 @@
@invocation(
- "call_saved_workflows",
- title="Call Saved Workflows",
+ "call_saved_workflow",
+ title="Call Saved Workflow",
tags=["workflow", "saved", "library"],
category="workflow",
version="1.0.0",
use_cache=False,
classification=Classification.Beta,
)
-class CallSavedWorkflowsInvocation(BaseInvocation):
- """Displays and later executes against the saved workflow library."""
+class CallSavedWorkflowInvocation(BaseInvocation):
+ """Displays and later executes against a selected saved workflow."""
workflow_id: str = InputField(
default="",
@@ -25,7 +25,7 @@ class CallSavedWorkflowsInvocation(BaseInvocation):
def invoke(self, context: InvocationContext) -> IntegerOutput:
if not self.workflow_id:
- raise ValueError("A saved workflow must be selected before executing call_saved_workflows.")
+ raise ValueError("A saved workflow must be selected before executing call_saved_workflow.")
try:
workflow_record = context._services.workflow_records.get(self.workflow_id)
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
index f6eef13f941..67581ec187d 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
@@ -72,12 +72,12 @@ export const add: InvocationTemplate = {
classification: 'stable',
};
-export const call_saved_workflows: InvocationTemplate = {
- title: 'Call Saved Workflows',
- type: 'call_saved_workflows',
+export const call_saved_workflow: InvocationTemplate = {
+ title: 'Call Saved Workflow',
+ type: 'call_saved_workflow',
version: '1.0.0',
tags: ['workflow', 'saved', 'library'],
- description: 'Displays and later executes against the saved workflow library.',
+ description: 'Displays and later executes against a selected saved workflow.',
outputType: 'integer_output',
inputs: {
workflow_id: {
@@ -579,7 +579,7 @@ const iterate: InvocationTemplate = {
export const templates: Templates = {
add,
- call_saved_workflows,
+ call_saved_workflow,
sub,
collect,
iterate,
@@ -597,7 +597,7 @@ export const schema = {
},
components: {
schemas: {
- CallSavedWorkflowsInvocation: {
+ CallSavedWorkflowInvocation: {
properties: {
id: {
type: 'string',
@@ -634,17 +634,17 @@ export const schema = {
},
type: {
type: 'string',
- enum: ['call_saved_workflows'],
- const: 'call_saved_workflows',
+ enum: ['call_saved_workflow'],
+ const: 'call_saved_workflow',
title: 'type',
- default: 'call_saved_workflows',
+ default: 'call_saved_workflow',
field_kind: 'node_attribute',
},
},
type: 'object',
required: ['type', 'id'],
- title: 'Call Saved Workflows',
- description: 'Displays and later executes against the saved workflow library.',
+ title: 'Call Saved Workflow',
+ description: 'Displays and later executes against a selected saved workflow.',
category: 'workflow',
classification: 'beta',
node_pack: 'invokeai',
diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
index f928d509ecf..99dc1131a3b 100644
--- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
@@ -1,5 +1,5 @@
import { omit, pick } from 'es-toolkit/compat';
-import { call_saved_workflows, schema, templates } from 'features/nodes/store/util/testUtils';
+import { call_saved_workflow, schema, templates } from 'features/nodes/store/util/testUtils';
import { parseSchema } from 'features/nodes/util/schema/parseSchema';
import { describe, expect, it } from 'vitest';
@@ -18,10 +18,10 @@ describe('parseSchema', () => {
const parsed = parseSchema(schema, ['add']);
expect(stripUndefinedDeep(parsed)).toEqual(stripUndefinedDeep(pick(templates, 'add')));
});
- it('should parse the call_saved_workflows node template', () => {
+ it('should parse the call_saved_workflow node template', () => {
const parsed = parseSchema(schema);
- expect(stripUndefinedDeep(parsed.call_saved_workflows)).toEqual(stripUndefinedDeep(call_saved_workflows));
- expect(parsed.call_saved_workflows.inputs.workflow_id.type.name).toBe('SavedWorkflowField');
- expect(parsed.call_saved_workflows.inputs.workflow_id.ui_type).toBe('SavedWorkflowField');
+ expect(stripUndefinedDeep(parsed.call_saved_workflow)).toEqual(stripUndefinedDeep(call_saved_workflow));
+ expect(parsed.call_saved_workflow.inputs.workflow_id.type.name).toBe('SavedWorkflowField');
+ expect(parsed.call_saved_workflow.inputs.workflow_id.ui_type).toBe('SavedWorkflowField');
});
});
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
index 2ceb2eda3e7..c637faefae9 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
@@ -1,9 +1,9 @@
-import { call_saved_workflows, buildNode } from 'features/nodes/store/util/testUtils';
import { getInitialWorkflow } from 'features/nodes/store/nodesSlice';
+import { buildNode,call_saved_workflow } from 'features/nodes/store/util/testUtils';
import { describe, expect, it } from 'vitest';
describe('buildWorkflowFast', () => {
- it('persists the selected workflow id for call_saved_workflows nodes', async () => {
+ it('persists the selected workflow id for call_saved_workflow nodes', async () => {
Object.assign(globalThis, {
window: {
location: {
@@ -13,7 +13,7 @@ describe('buildWorkflowFast', () => {
});
const { buildWorkflowFast } = await import('features/nodes/util/workflow/buildWorkflow');
- const node = buildNode(call_saved_workflows);
+ const node = buildNode(call_saved_workflow);
node.data.inputs.workflow_id.value = 'workflow-123';
const workflow = buildWorkflowFast({
@@ -29,7 +29,7 @@ describe('buildWorkflowFast', () => {
if (workflow.nodes[0]?.type !== 'invocation') {
throw new Error('Expected invocation node');
}
- expect(workflow.nodes[0].data.type).toBe('call_saved_workflows');
+ expect(workflow.nodes[0].data.type).toBe('call_saved_workflow');
expect(workflow.nodes[0].data.inputs.workflow_id.value).toBe('workflow-123');
});
});
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
index fdb59cf95c9..2664f017eb1 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.test.ts
@@ -97,7 +97,7 @@ describe('setEventListeners workflow live updates', () => {
);
});
- it('clears selected workflow ids from call_saved_workflows nodes on workflow_deleted', () => {
+ it('clears selected workflow ids from call_saved_workflow nodes on workflow_deleted', () => {
const socket = createMockSocket();
const dispatch = vi.fn();
const store = {
@@ -111,7 +111,7 @@ describe('setEventListeners workflow live updates', () => {
type: 'invocation',
data: {
id: 'call-saved-workflows-node',
- type: 'call_saved_workflows',
+ type: 'call_saved_workflow',
inputs: {
workflow_id: {
value: 'wf-1',
@@ -144,7 +144,7 @@ describe('setEventListeners workflow live updates', () => {
);
});
- it('does not clear selected workflow ids from call_saved_workflows nodes on workflow_updated', () => {
+ it('does not clear selected workflow ids from call_saved_workflow nodes on workflow_updated', () => {
const socket = createMockSocket();
const dispatch = vi.fn();
const store = {
@@ -158,7 +158,7 @@ describe('setEventListeners workflow live updates', () => {
type: 'invocation',
data: {
id: 'call-saved-workflows-node',
- type: 'call_saved_workflows',
+ type: 'call_saved_workflow',
inputs: {
workflow_id: {
value: 'wf-1',
@@ -215,7 +215,7 @@ describe('setEventListeners workflow live updates', () => {
type: 'invocation',
data: {
id: 'call-saved-workflows-node',
- type: 'call_saved_workflows',
+ type: 'call_saved_workflow',
inputs: {
workflow_id: {
value: 'wf-shared',
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
index bbed2264800..ffdbdcd93f0 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
@@ -128,7 +128,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
const nodes = selectNodesSlice(getState()).nodes;
for (const node of nodes) {
- if (node.type !== 'invocation' || node.data.type !== 'call_saved_workflows') {
+ if (node.type !== 'invocation' || node.data.type !== 'call_saved_workflow') {
continue;
}
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index fc61f60f00b..b2b6c224863 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -75,7 +75,9 @@ def build_context(
users=Mock(),
workflow_records=Mock(),
)
- services.users.get.return_value = build_user_dto(user_id=queue_user_id, is_admin=user_is_admin) if user_exists else None
+ services.users.get.return_value = (
+ build_user_dto(user_id=queue_user_id, is_admin=user_is_admin) if user_exists else None
+ )
if workflow_not_found:
services.workflow_records.get.side_effect = WorkflowNotFoundError("missing")
@@ -88,13 +90,13 @@ def build_context(
return context
-def test_call_saved_workflows_invocation_contract():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_contract():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
from invokeai.app.invocations.primitives import IntegerOutput
- invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="workflow-123")
+ invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="workflow-123")
- assert invocation.get_type() == "call_saved_workflows"
+ assert invocation.get_type() == "call_saved_workflow"
assert invocation.workflow_id == "workflow-123"
output = invocation.invoke(build_context())
@@ -103,28 +105,28 @@ def test_call_saved_workflows_invocation_contract():
assert output.value == 0
-def test_call_saved_workflows_invocation_raises_when_workflow_id_is_empty():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_raises_when_workflow_id_is_empty():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- invocation = CallSavedWorkflowsInvocation(id="test-node")
+ invocation = CallSavedWorkflowInvocation(id="test-node")
with pytest.raises(ValueError, match="saved workflow must be selected"):
invocation.invoke(build_context())
-def test_call_saved_workflows_invocation_raises_when_workflow_does_not_exist():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_raises_when_workflow_does_not_exist():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="missing-workflow")
+ invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="missing-workflow")
with pytest.raises(ValueError, match="could not be found"):
invocation.invoke(build_context(workflow_not_found=True))
-def test_call_saved_workflows_invocation_raises_when_workflow_is_not_accessible():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_raises_when_workflow_is_not_accessible():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="private-workflow")
+ invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
with pytest.raises(ValueError, match="is not accessible"):
invocation.invoke(
@@ -142,10 +144,10 @@ def test_call_saved_workflows_invocation_raises_when_workflow_is_not_accessible(
)
-def test_call_saved_workflows_invocation_allows_shared_workflow_for_non_owner():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_allows_shared_workflow_for_non_owner():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="shared-workflow")
+ invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="shared-workflow")
output = invocation.invoke(
build_context(
@@ -164,10 +166,10 @@ def test_call_saved_workflows_invocation_allows_shared_workflow_for_non_owner():
assert output.value == 0
-def test_call_saved_workflows_invocation_allows_default_workflow_for_non_owner():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_allows_default_workflow_for_non_owner():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="default-workflow")
+ invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="default-workflow")
output = invocation.invoke(
build_context(
@@ -186,10 +188,10 @@ def test_call_saved_workflows_invocation_allows_default_workflow_for_non_owner()
assert output.value == 0
-def test_call_saved_workflows_invocation_allows_admin_to_access_private_workflow():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_allows_admin_to_access_private_workflow():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="private-workflow")
+ invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
output = invocation.invoke(
build_context(
@@ -208,10 +210,10 @@ def test_call_saved_workflows_invocation_allows_admin_to_access_private_workflow
assert output.value == 0
-def test_call_saved_workflows_invocation_raises_when_private_workflow_user_record_is_missing():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_raises_when_private_workflow_user_record_is_missing():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- invocation = CallSavedWorkflowsInvocation(id="test-node", workflow_id="private-workflow")
+ invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
with pytest.raises(ValueError, match="is not accessible"):
invocation.invoke(
@@ -229,10 +231,10 @@ def test_call_saved_workflows_invocation_raises_when_private_workflow_user_recor
)
-def test_call_saved_workflows_invocation_schema_declares_saved_workflow_ui_type():
- from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
+def test_call_saved_workflow_invocation_schema_declares_saved_workflow_ui_type():
+ from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- schema = CallSavedWorkflowsInvocation.model_json_schema()
+ schema = CallSavedWorkflowInvocation.model_json_schema()
workflow_id = schema["properties"]["workflow_id"]
assert workflow_id["default"] == ""
From b3657d192cd3800758569d6e1075c8dd3b3000e8 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 17:52:49 -0500
Subject: [PATCH 020/100] Fix saved workflow frontend build issues
---
.../fields/inputs/savedWorkflowFieldUtils.ts | 10 +-
.../web/src/features/nodes/types/field.ts | 3 +-
.../util/schema/buildFieldInputInstance.ts | 1 +
.../nodes/util/schema/parseSchema.test.ts | 12 +-
.../nodes/util/workflow/buildWorkflow.test.ts | 14 +-
.../frontend/web/src/services/api/schema.ts | 159 ++++++++++++++++--
6 files changed, 173 insertions(+), 26 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index f588a41a0df..d98b41e8fa0 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -5,14 +5,12 @@ export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
export const MISSING_SELECTION_LABEL = 'Missing or inaccessible workflow';
export const EMPTY_SELECTION_LABEL = 'Choose a workflow';
-export type SavedWorkflowSelectionState =
+type SavedWorkflowSelectionState =
| { status: 'unselected' }
| { status: 'selected'; workflow: WorkflowRecordListItemWithThumbnailDTO }
| { status: 'missing'; workflowId: string };
-export const buildSavedWorkflowOptions = (
- workflows: WorkflowRecordListItemWithThumbnailDTO[]
-): ComboboxOption[] => {
+export const buildSavedWorkflowOptions = (workflows: WorkflowRecordListItemWithThumbnailDTO[]): ComboboxOption[] => {
return workflows.map((workflow) => ({
label: workflow.name,
value: workflow.workflow_id,
@@ -35,9 +33,7 @@ export const getSavedWorkflowSelectionState = (
return { status: 'missing', workflowId };
};
-export const getSavedWorkflowSelectionOption = (
- selectionState: SavedWorkflowSelectionState
-): ComboboxOption | null => {
+export const getSavedWorkflowSelectionOption = (selectionState: SavedWorkflowSelectionState): ComboboxOption | null => {
if (selectionState.status === 'unselected') {
return null;
}
diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts
index aff8fa097b4..24302fd4179 100644
--- a/invokeai/frontend/web/src/features/nodes/types/field.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/field.ts
@@ -702,7 +702,7 @@ export const isSchedulerFieldInputTemplate = buildTemplateTypeGuard;
export type SavedWorkflowFieldInputInstance = z.infer;
export type SavedWorkflowFieldInputTemplate = z.infer;
export const isSavedWorkflowFieldInputInstance = buildInstanceTypeGuard(zSavedWorkflowFieldInputInstance);
diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts
index ef7b92efdd8..6bb3155f476 100644
--- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts
@@ -11,6 +11,7 @@ const FIELD_VALUE_FALLBACK_MAP: Record =
IntegerField: 0,
ModelIdentifierField: undefined,
SchedulerField: 'dpmpp_3m_k',
+ SavedWorkflowField: '',
StringField: '',
StylePresetField: undefined,
FloatGeneratorField: undefined,
diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
index 99dc1131a3b..a126b7415ea 100644
--- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
@@ -21,7 +21,15 @@ describe('parseSchema', () => {
it('should parse the call_saved_workflow node template', () => {
const parsed = parseSchema(schema);
expect(stripUndefinedDeep(parsed.call_saved_workflow)).toEqual(stripUndefinedDeep(call_saved_workflow));
- expect(parsed.call_saved_workflow.inputs.workflow_id.type.name).toBe('SavedWorkflowField');
- expect(parsed.call_saved_workflow.inputs.workflow_id.ui_type).toBe('SavedWorkflowField');
+ const template = parsed.call_saved_workflow;
+ if (!template) {
+ throw new Error('Expected call_saved_workflow template');
+ }
+ const workflowIdInput = template.inputs.workflow_id;
+ if (!workflowIdInput) {
+ throw new Error('Expected workflow_id input');
+ }
+ expect(workflowIdInput.type.name).toBe('SavedWorkflowField');
+ expect(workflowIdInput.ui_type).toBe('SavedWorkflowField');
});
});
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
index c637faefae9..c7bdd80acea 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.test.ts
@@ -1,5 +1,5 @@
import { getInitialWorkflow } from 'features/nodes/store/nodesSlice';
-import { buildNode,call_saved_workflow } from 'features/nodes/store/util/testUtils';
+import { buildNode, call_saved_workflow } from 'features/nodes/store/util/testUtils';
import { describe, expect, it } from 'vitest';
describe('buildWorkflowFast', () => {
@@ -14,7 +14,11 @@ describe('buildWorkflowFast', () => {
const { buildWorkflowFast } = await import('features/nodes/util/workflow/buildWorkflow');
const node = buildNode(call_saved_workflow);
- node.data.inputs.workflow_id.value = 'workflow-123';
+ const workflowIdInput = node.data.inputs.workflow_id;
+ if (!workflowIdInput) {
+ throw new Error('Expected workflow_id input');
+ }
+ workflowIdInput.value = 'workflow-123';
const workflow = buildWorkflowFast({
_version: 1,
@@ -30,6 +34,10 @@ describe('buildWorkflowFast', () => {
throw new Error('Expected invocation node');
}
expect(workflow.nodes[0].data.type).toBe('call_saved_workflow');
- expect(workflow.nodes[0].data.inputs.workflow_id.value).toBe('workflow-123');
+ const serializedWorkflowIdInput = workflow.nodes[0].data.inputs.workflow_id;
+ if (!serializedWorkflowIdInput) {
+ throw new Error('Expected serialized workflow_id input');
+ }
+ expect(serializedWorkflowIdInput.value).toBe('workflow-123');
});
});
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index d19b5731544..09927852973 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -1049,7 +1049,7 @@ export type paths = {
post?: never;
/**
* Clear Intermediates
- * @description Clears all intermediates
+ * @description Clears all intermediates. Requires admin.
*/
delete: operations["clear_intermediates"];
options?: never;
@@ -1103,7 +1103,11 @@ export type paths = {
};
/**
* Get Image Full
- * @description Gets a full-resolution image file
+ * @description Gets a full-resolution image file.
+ *
+ * This endpoint is intentionally unauthenticated because browsers load images
+ * via
tags which cannot send Bearer tokens. Image names are UUIDs,
+ * providing security through unguessability.
*/
get: operations["get_image_full"];
put?: never;
@@ -1112,7 +1116,11 @@ export type paths = {
options?: never;
/**
* Get Image Full
- * @description Gets a full-resolution image file
+ * @description Gets a full-resolution image file.
+ *
+ * This endpoint is intentionally unauthenticated because browsers load images
+ * via
tags which cannot send Bearer tokens. Image names are UUIDs,
+ * providing security through unguessability.
*/
head: operations["get_image_full_head"];
patch?: never;
@@ -1127,7 +1135,11 @@ export type paths = {
};
/**
* Get Image Thumbnail
- * @description Gets a thumbnail image file
+ * @description Gets a thumbnail image file.
+ *
+ * This endpoint is intentionally unauthenticated because browsers load images
+ * via
tags which cannot send Bearer tokens. Image names are UUIDs,
+ * providing security through unguessability.
*/
get: operations["get_image_thumbnail"];
put?: never;
@@ -1187,7 +1199,7 @@ export type paths = {
post?: never;
/**
* Delete Uncategorized Images
- * @description Deletes all images that are uncategorized
+ * @description Deletes all uncategorized images owned by the current user (or all if admin)
*/
delete: operations["delete_uncategorized_images"];
options?: never;
@@ -2163,7 +2175,11 @@ export type paths = {
};
/**
* Get Workflow Thumbnail
- * @description Gets a workflow's thumbnail image
+ * @description Gets a workflow's thumbnail image.
+ *
+ * This endpoint is intentionally unauthenticated because browsers load images
+ * via
tags which cannot send Bearer tokens. Workflow IDs are UUIDs,
+ * providing security through unguessability.
*/
get: operations["get_workflow_thumbnail"];
/**
@@ -4425,6 +4441,41 @@ export type components = {
*/
type: "calculate_image_tiles_output";
};
+ /**
+ * Call Saved Workflow
+ * @description Displays and later executes against a selected saved workflow.
+ */
+ CallSavedWorkflowInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default false
+ */
+ use_cache?: boolean;
+ /**
+ * Workflow Id
+ * @description The selected saved workflow ID, managed by the workflow editor UI.
+ * @default
+ */
+ workflow_id?: string;
+ /**
+ * type
+ * @default call_saved_workflow
+ * @constant
+ */
+ type: "call_saved_workflow";
+ };
/**
* CancelAllExceptCurrentResult
* @description Result of canceling all except current
@@ -10827,7 +10878,7 @@ export type components = {
* @description The nodes in this graph
*/
nodes?: {
- [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
};
/**
* Edges
@@ -14111,7 +14162,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -14175,7 +14226,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -14209,6 +14260,7 @@ export type components = {
calculate_image_tiles: components["schemas"]["CalculateImageTilesOutput"];
calculate_image_tiles_even_split: components["schemas"]["CalculateImageTilesOutput"];
calculate_image_tiles_min_overlap: components["schemas"]["CalculateImageTilesOutput"];
+ call_saved_workflow: components["schemas"]["IntegerOutput"];
canny_edge_detection: components["schemas"]["ImageOutput"];
canvas_output: components["schemas"]["ImageOutput"];
canvas_paste_back: components["schemas"]["ImageOutput"];
@@ -14484,7 +14536,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -14559,7 +14611,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -26886,7 +26938,7 @@ export type components = {
* used, and the type will be ignored. They are included here for backwards compatibility.
* @enum {string}
*/
- UIType: "SchedulerField" | "AnyField" | "CollectionField" | "CollectionItemField" | "IsIntermediate" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict" | "DEPRECATED_MainModelField" | "DEPRECATED_CogView4MainModelField" | "DEPRECATED_FluxMainModelField" | "DEPRECATED_SD3MainModelField" | "DEPRECATED_SDXLMainModelField" | "DEPRECATED_SDXLRefinerModelField" | "DEPRECATED_ONNXModelField" | "DEPRECATED_VAEModelField" | "DEPRECATED_FluxVAEModelField" | "DEPRECATED_LoRAModelField" | "DEPRECATED_ControlNetModelField" | "DEPRECATED_IPAdapterModelField" | "DEPRECATED_T2IAdapterModelField" | "DEPRECATED_T5EncoderModelField" | "DEPRECATED_CLIPEmbedModelField" | "DEPRECATED_CLIPLEmbedModelField" | "DEPRECATED_CLIPGEmbedModelField" | "DEPRECATED_SpandrelImageToImageModelField" | "DEPRECATED_ControlLoRAModelField" | "DEPRECATED_SigLipModelField" | "DEPRECATED_FluxReduxModelField" | "DEPRECATED_LLaVAModelField" | "DEPRECATED_Imagen3ModelField" | "DEPRECATED_Imagen4ModelField" | "DEPRECATED_ChatGPT4oModelField" | "DEPRECATED_Gemini2_5ModelField" | "DEPRECATED_FluxKontextModelField" | "DEPRECATED_Veo3ModelField" | "DEPRECATED_RunwayModelField";
+ UIType: "SchedulerField" | "AnyField" | "SavedWorkflowField" | "CollectionField" | "CollectionItemField" | "IsIntermediate" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict" | "DEPRECATED_MainModelField" | "DEPRECATED_CogView4MainModelField" | "DEPRECATED_FluxMainModelField" | "DEPRECATED_SD3MainModelField" | "DEPRECATED_SDXLMainModelField" | "DEPRECATED_SDXLRefinerModelField" | "DEPRECATED_ONNXModelField" | "DEPRECATED_VAEModelField" | "DEPRECATED_FluxVAEModelField" | "DEPRECATED_LoRAModelField" | "DEPRECATED_ControlNetModelField" | "DEPRECATED_IPAdapterModelField" | "DEPRECATED_T2IAdapterModelField" | "DEPRECATED_T5EncoderModelField" | "DEPRECATED_CLIPEmbedModelField" | "DEPRECATED_CLIPLEmbedModelField" | "DEPRECATED_CLIPGEmbedModelField" | "DEPRECATED_SpandrelImageToImageModelField" | "DEPRECATED_ControlLoRAModelField" | "DEPRECATED_SigLipModelField" | "DEPRECATED_FluxReduxModelField" | "DEPRECATED_LLaVAModelField" | "DEPRECATED_Imagen3ModelField" | "DEPRECATED_Imagen4ModelField" | "DEPRECATED_ChatGPT4oModelField" | "DEPRECATED_Gemini2_5ModelField" | "DEPRECATED_FluxKontextModelField" | "DEPRECATED_Veo3ModelField" | "DEPRECATED_RunwayModelField";
/** UNetField */
UNetField: {
/** @description Info to load unet submodel */
@@ -27909,6 +27961,58 @@ export type components = {
* @enum {string}
*/
WorkflowCategory: "user" | "default";
+ /**
+ * WorkflowCreatedEvent
+ * @description Event model for workflow_created
+ */
+ WorkflowCreatedEvent: {
+ /**
+ * Timestamp
+ * @description The timestamp of the event
+ */
+ timestamp: number;
+ /**
+ * Workflow Id
+ * @description The ID of the workflow
+ */
+ workflow_id: string;
+ /**
+ * User Id
+ * @description The owner of the workflow
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether the workflow is shared with all users
+ */
+ is_public: boolean;
+ };
+ /**
+ * WorkflowDeletedEvent
+ * @description Event model for workflow_deleted
+ */
+ WorkflowDeletedEvent: {
+ /**
+ * Timestamp
+ * @description The timestamp of the event
+ */
+ timestamp: number;
+ /**
+ * Workflow Id
+ * @description The ID of the workflow
+ */
+ workflow_id: string;
+ /**
+ * User Id
+ * @description The owner of the workflow
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether the workflow was shared when it was deleted
+ */
+ is_public: boolean;
+ };
/** WorkflowMeta */
WorkflowMeta: {
/**
@@ -28065,6 +28169,37 @@ export type components = {
*/
thumbnail_url?: string | null;
};
+ /**
+ * WorkflowUpdatedEvent
+ * @description Event model for workflow_updated
+ */
+ WorkflowUpdatedEvent: {
+ /**
+ * Timestamp
+ * @description The timestamp of the event
+ */
+ timestamp: number;
+ /**
+ * Workflow Id
+ * @description The ID of the workflow
+ */
+ workflow_id: string;
+ /**
+ * User Id
+ * @description The owner of the workflow
+ */
+ user_id: string;
+ /**
+ * Old Is Public
+ * @description Whether the workflow was shared before the update
+ */
+ old_is_public: boolean;
+ /**
+ * New Is Public
+ * @description Whether the workflow is shared after the update
+ */
+ new_is_public: boolean;
+ };
/** WorkflowWithoutID */
WorkflowWithoutID: {
/**
From 37ca6b960874a2f795f29a7ef61b44aa35598f57 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 19:31:36 -0500
Subject: [PATCH 021/100] Add dynamic saved workflow form fields
---
.../Invocation/CallSavedWorkflowNode.tsx | 401 ++++++++++++++++++
.../Invocation/InvocationNodeWrapper.tsx | 7 +-
.../callSavedWorkflowFormUtils.test.ts | 185 ++++++++
.../Invocation/callSavedWorkflowFormUtils.ts | 244 +++++++++++
.../src/features/nodes/store/nodesSlice.ts | 46 ++
5 files changed, 882 insertions(+), 1 deletion(-)
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
new file mode 100644
index 00000000000..16bdb140995
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
@@ -0,0 +1,401 @@
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Badge, Flex, Grid, GridItem, Text } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
+import { FloatFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput';
+import { FloatFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider';
+import { FloatFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldSlider';
+import { InputFieldEditModeNodes } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes';
+import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
+import BoardFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/BoardFieldInputComponent';
+import BooleanFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/BooleanFieldInputComponent';
+import ColorFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ColorFieldInputComponent';
+import EnumFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/EnumFieldInputComponent';
+import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelIdentifierFieldInputComponent';
+import SchedulerFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/SchedulerFieldInputComponent';
+import StylePresetFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StylePresetFieldInputComponent';
+import { IntegerFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput';
+import { IntegerFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider';
+import { IntegerFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider';
+import { OutputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate';
+import { OutputFieldNodesEditorView } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView';
+import { StringFieldDropdown } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldDropdown';
+import { StringFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldInput';
+import { StringFieldTextarea } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldTextarea';
+import InvocationNodeFooter from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter';
+import InvocationNodeHeader from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeHeader';
+import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
+import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
+import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
+import { $templates, callSavedWorkflowDynamicFieldsChanged } from 'features/nodes/store/nodesSlice';
+import type {
+ BoardFieldInputInstance,
+ BoardFieldInputTemplate,
+ BooleanFieldInputInstance,
+ BooleanFieldInputTemplate,
+ ColorFieldInputInstance,
+ ColorFieldInputTemplate,
+ EnumFieldInputInstance,
+ EnumFieldInputTemplate,
+ FieldInputInstance,
+ FieldInputTemplate,
+ FloatFieldInputInstance,
+ FloatFieldInputTemplate,
+ IntegerFieldInputInstance,
+ IntegerFieldInputTemplate,
+ ModelIdentifierFieldInputInstance,
+ ModelIdentifierFieldInputTemplate,
+ SavedWorkflowFieldInputInstance,
+ SchedulerFieldInputInstance,
+ SchedulerFieldInputTemplate,
+ StringFieldInputInstance,
+ StringFieldInputTemplate,
+ StylePresetFieldInputInstance,
+ StylePresetFieldInputTemplate,
+} from 'features/nodes/types/field';
+import {
+ isBoardFieldInputInstance,
+ isBooleanFieldInputInstance,
+ isColorFieldInputInstance,
+ isEnumFieldInputInstance,
+ isFloatFieldInputInstance,
+ isIntegerFieldInputInstance,
+ isModelIdentifierFieldInputInstance,
+ isSchedulerFieldInputInstance,
+ isStringFieldInputInstance,
+ isStylePresetFieldInputInstance,
+} from 'features/nodes/types/field';
+import type { NodeFieldElement } from 'features/nodes/types/workflow';
+import { memo, useEffect, useMemo } from 'react';
+import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
+
+import { getSavedWorkflowDynamicFields } from './callSavedWorkflowFormUtils';
+
+const bodySx: SystemStyleObject = {
+ flexDirection: 'column',
+ w: 'full',
+ h: 'full',
+ py: 2,
+ gap: 1,
+ borderBottomRadius: 'base',
+ '&[data-with-footer="true"]': {
+ borderBottomRadius: 0,
+ },
+ '&[data-with-footer="false"]': {
+ pb: 4,
+ },
+};
+
+const dynamicFieldSx: SystemStyleObject = {
+ px: 2,
+ py: 1,
+ gap: 1,
+ flexDir: 'column',
+ w: 'full',
+};
+
+const fieldBodySx: SystemStyleObject = {
+ borderWidth: '1px',
+ borderRadius: 'base',
+ borderColor: 'base.700',
+ px: 2,
+ py: 1.5,
+ minH: 12,
+};
+
+type Props = {
+ nodeId: string;
+ isOpen: boolean;
+};
+
+const CallSavedWorkflowNode = ({ nodeId, isOpen }: Props) => {
+ const withFooter = useWithFooter();
+ const workflowIdField = useInputFieldInstance('workflow_id');
+ const templates = useStore($templates);
+ const dispatch = useAppDispatch();
+
+ const { data: workflow } = useGetWorkflowQuery(workflowIdField.value, {
+ skip: !workflowIdField.value,
+ });
+
+ const dynamicFields = useMemo(() => getSavedWorkflowDynamicFields(workflow, templates), [templates, workflow]);
+
+ useEffect(() => {
+ dispatch(callSavedWorkflowDynamicFieldsChanged({ nodeId, fields: dynamicFields }));
+ }, [dispatch, dynamicFields, nodeId]);
+
+ return (
+ <>
+
+ {isOpen && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {withFooter && }
+ >
+ )}
+ >
+ );
+};
+
+export default memo(CallSavedWorkflowNode);
+
+const DynamicFieldsSection = memo(
+ ({ nodeId, fields }: { nodeId: string; fields: ReturnType }) => {
+ if (fields.length === 0) {
+ return (
+
+ Select a workflow with exposed form fields
+
+ );
+ }
+
+ return (
+ <>
+ {fields.map((field) => (
+
+ ))}
+ >
+ );
+ }
+);
+DynamicFieldsSection.displayName = 'DynamicFieldsSection';
+
+const DynamicFieldRow = memo(
+ ({ nodeId, field }: { nodeId: string; field: ReturnType[number] }) => {
+ const ctx = useInvocationNodeContext();
+ const selector = useMemo(() => ctx.buildSelectInputFieldSafe(field.fieldName), [ctx, field.fieldName]);
+ const instance = useAppSelector(selector);
+
+ if (!instance) {
+ return null;
+ }
+
+ return (
+
+
+ {instance.label || field.fieldTemplate.title}
+
+
+
+
+
+ );
+ }
+);
+DynamicFieldRow.displayName = 'DynamicFieldRow';
+
+const DynamicFieldInputRenderer = memo(
+ ({
+ nodeId,
+ instance,
+ template,
+ settings,
+ }: {
+ nodeId: string;
+ instance: FieldInputInstance;
+ template: FieldInputTemplate;
+ settings: NodeFieldElement['data']['settings'];
+ }) => {
+ if (template.type.name === 'StringField' && isStringFieldInputInstance(instance)) {
+ if (settings?.type === 'string-field-config' && settings.component === 'textarea') {
+ return (
+
+ );
+ }
+ if (settings?.type === 'string-field-config' && settings.component === 'dropdown') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'IntegerField' && isIntegerFieldInputInstance(instance)) {
+ if (settings?.type === 'integer-field-config' && settings.component === 'slider') {
+ return (
+
+ );
+ }
+ if (settings?.type === 'integer-field-config' && settings.component === 'number-input-and-slider') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'FloatField' && isFloatFieldInputInstance(instance)) {
+ if (settings?.type === 'float-field-config' && settings.component === 'slider') {
+ return (
+
+ );
+ }
+ if (settings?.type === 'float-field-config' && settings.component === 'number-input-and-slider') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'BooleanField' && isBooleanFieldInputInstance(instance)) {
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'EnumField' && isEnumFieldInputInstance(instance)) {
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'BoardField' && isBoardFieldInputInstance(instance)) {
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'ModelIdentifierField' && isModelIdentifierFieldInputInstance(instance)) {
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'SchedulerField' && isSchedulerFieldInputInstance(instance)) {
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'ColorField' && isColorFieldInputInstance(instance)) {
+ return (
+
+ );
+ }
+
+ if (template.type.name === 'StylePresetField' && isStylePresetFieldInputInstance(instance)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ Unsupported dynamic field type: {template.type.name}
+
+ );
+ }
+);
+DynamicFieldInputRenderer.displayName = 'DynamicFieldInputRenderer';
+
+const OutputFields = memo(({ nodeId }: { nodeId: string }) => {
+ const fieldNames = useOutputFieldNames();
+ return (
+ <>
+ {fieldNames.map((fieldName, i) => (
+
+
+
+
+
+ ))}
+ >
+ );
+});
+OutputFields.displayName = 'OutputFields';
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
index 7a4ea9ca65c..1961e4dc751 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx
@@ -3,6 +3,7 @@ import { createSelector } from '@reduxjs/toolkit';
import type { Node, NodeProps } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
+import CallSavedWorkflowNode from 'features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode';
import InvocationNode from 'features/nodes/components/flow/nodes/Invocation/InvocationNode';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
@@ -40,7 +41,11 @@ const InvocationNodeWrapper = (props: NodeProps>) => {
return (
-
+ {type === 'call_saved_workflow' ? (
+
+ ) : (
+
+ )}
);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
new file mode 100644
index 00000000000..4751ceff6e7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
@@ -0,0 +1,185 @@
+import { templates } from 'features/nodes/store/util/testUtils';
+import {
+ type BuilderForm,
+ buildHeading,
+ buildNodeFieldElement,
+ getDefaultForm,
+ isContainerElement,
+} from 'features/nodes/types/workflow';
+import type { paths } from 'services/api/schema';
+import { describe, expect, it } from 'vitest';
+
+import {
+ getRenderableWorkflowForm,
+ getSavedWorkflowDynamicFields,
+ getSavedWorkflowFormFieldData,
+} from './callSavedWorkflowFormUtils';
+
+type WorkflowResponse =
+ paths['/api/v1/workflows/i/{workflow_id}']['get']['responses']['200']['content']['application/json'];
+
+const addTemplate = templates.add;
+if (!addTemplate) {
+ throw new Error('Expected add template');
+}
+const addInputA = addTemplate.inputs.a;
+const addInputB = addTemplate.inputs.b;
+if (!addInputA || !addInputB) {
+ throw new Error('Expected add template inputs');
+}
+
+const getRootChildren = (form: BuilderForm): string[] => {
+ const root = form.elements[form.rootElementId];
+
+ if (!root || !isContainerElement(root)) {
+ throw new Error('Expected root container');
+ }
+
+ return root.data.children;
+};
+
+const buildWorkflowResponse = (overrides?: {
+ exposedFields?: Array<{ nodeId: string; fieldName: string }>;
+ form?: BuilderForm | null;
+ inputs?: Record;
+}): WorkflowResponse =>
+ ({
+ workflow_id: 'workflow-1',
+ name: 'Workflow 1',
+ created_at: '2026-04-08T00:00:00Z',
+ updated_at: '2026-04-08T00:00:00Z',
+ opened_at: null,
+ user_id: 'user-1',
+ is_public: false,
+ thumbnail_url: null,
+ workflow: {
+ id: 'workflow-1',
+ name: 'Workflow 1',
+ author: 'InvokeAI',
+ description: 'A workflow',
+ version: '1.0.0',
+ contact: '',
+ tags: '',
+ notes: '',
+ exposedFields: overrides?.exposedFields ?? [],
+ meta: {
+ category: 'user',
+ version: '3.0.0',
+ },
+ nodes: [
+ {
+ id: 'node-1',
+ type: 'invocation',
+ data: {
+ id: 'node-1',
+ type: 'add',
+ inputs: overrides?.inputs ?? {
+ a: { name: 'a', label: 'Left Addend', value: 1 },
+ b: { name: 'b', label: '', value: 2 },
+ },
+ },
+ },
+ ],
+ edges: [],
+ form: overrides?.form ?? getDefaultForm(),
+ },
+ }) as WorkflowResponse;
+
+describe('callSavedWorkflowFormUtils', () => {
+ it('returns the stored form when it is non-empty and valid', () => {
+ const form = getDefaultForm();
+ const heading = buildHeading('Workflow Inputs');
+ form.elements[heading.id] = { ...heading, parentId: form.rootElementId };
+ getRootChildren(form).push(heading.id);
+
+ const workflow = buildWorkflowResponse({ form });
+
+ expect(getRenderableWorkflowForm(workflow, templates)).toBe(form);
+ });
+
+ it('builds a fallback form from exposed fields when the stored form is empty', () => {
+ const workflow = buildWorkflowResponse({
+ exposedFields: [{ nodeId: 'node-1', fieldName: 'a' }],
+ });
+
+ const form = getRenderableWorkflowForm(workflow, templates);
+
+ expect(form).not.toBeNull();
+ expect(form ? getRootChildren(form) : []).toHaveLength(1);
+ const childId = form ? getRootChildren(form)[0] : undefined;
+ expect(childId).toBeDefined();
+ expect(childId ? form?.elements[childId]?.type : undefined).toBe('node-field');
+ });
+
+ it('skips exposed fields that do not resolve to a known node field', () => {
+ const workflow = buildWorkflowResponse({
+ exposedFields: [{ nodeId: 'missing-node', fieldName: 'a' }],
+ });
+
+ const form = getRenderableWorkflowForm(workflow, templates);
+
+ expect(form).not.toBeNull();
+ expect(form ? getRootChildren(form) : []).toHaveLength(0);
+ });
+
+ it('uses the stored field label when available', () => {
+ const element = buildNodeFieldElement('node-1', 'a', addInputA.type);
+ const workflow = buildWorkflowResponse();
+
+ expect(getSavedWorkflowFormFieldData(workflow, templates, element)).toEqual(
+ expect.objectContaining({
+ label: 'Left Addend',
+ description: 'The first number',
+ typeName: 'IntegerField',
+ isMissing: false,
+ })
+ );
+ });
+
+ it('falls back to the template title when the stored field label is empty', () => {
+ const element = buildNodeFieldElement('node-1', 'b', addInputB.type);
+ const workflow = buildWorkflowResponse();
+
+ expect(getSavedWorkflowFormFieldData(workflow, templates, element)).toEqual(
+ expect.objectContaining({
+ label: 'B',
+ description: 'The second number',
+ typeName: 'IntegerField',
+ isMissing: false,
+ })
+ );
+ });
+
+ it('marks missing node field references as missing', () => {
+ const element = buildNodeFieldElement('missing-node', 'a', addInputA.type);
+ const workflow = buildWorkflowResponse();
+
+ expect(getSavedWorkflowFormFieldData(workflow, templates, element)).toEqual(
+ expect.objectContaining({
+ label: 'a',
+ description: '',
+ typeName: null,
+ isMissing: true,
+ })
+ );
+ });
+
+ it('builds ordered dynamic fields from the workflow form', () => {
+ const workflow = buildWorkflowResponse({
+ exposedFields: [
+ { nodeId: 'node-1', fieldName: 'a' },
+ { nodeId: 'node-1', fieldName: 'b' },
+ ],
+ });
+
+ const dynamicFields = getSavedWorkflowDynamicFields(workflow, templates);
+
+ expect(dynamicFields).toHaveLength(2);
+ expect(dynamicFields[0]?.fieldName).toBe('saved_workflow_input::node-1::a');
+ expect(dynamicFields[1]?.fieldName).toBe('saved_workflow_input::node-1::b');
+ expect(dynamicFields[0]?.fieldTemplate.title).toBe('Left Addend');
+ expect(dynamicFields[1]?.fieldTemplate.title).toBe('B');
+ expect(dynamicFields[0]?.fieldTemplate.name).toBe(dynamicFields[0]?.fieldName);
+ expect(dynamicFields[1]?.fieldTemplate.ui_order).toBe(1);
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
new file mode 100644
index 00000000000..58a137f5389
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
@@ -0,0 +1,244 @@
+import { addElement, getIsFormEmpty } from 'features/nodes/components/sidePanel/builder/form-manipulation';
+import { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX } from 'features/nodes/store/nodesSlice';
+import type { Templates } from 'features/nodes/store/types';
+import type { FieldInputTemplate } from 'features/nodes/types/field';
+import { isStatefulFieldType } from 'features/nodes/types/field';
+import {
+ type BuilderForm,
+ buildNodeFieldElement,
+ type FormElement,
+ getDefaultForm,
+ isContainerElement,
+ type NodeFieldElement,
+ validateFormStructure,
+} from 'features/nodes/types/workflow';
+import type { paths } from 'services/api/schema';
+
+type WorkflowResponse =
+ paths['/api/v1/workflows/i/{workflow_id}']['get']['responses']['200']['content']['application/json'];
+
+type WorkflowNodeLike = {
+ data?: {
+ id?: string;
+ type?: string;
+ inputs?: Record;
+ };
+};
+
+type ExposedFieldLike = {
+ nodeId?: string;
+ fieldName?: string;
+};
+
+type SavedWorkflowFormFieldData = {
+ label: string;
+ description: string;
+ typeName: string | null;
+ isMissing: boolean;
+};
+
+type SavedWorkflowDynamicField = {
+ fieldName: string;
+ fieldTemplate: FieldInputTemplate;
+ label: string;
+ description: string;
+ settings: NodeFieldElement['data']['settings'];
+};
+
+const getStoredForm = (workflow: WorkflowResponse | undefined): BuilderForm | null => {
+ const form = workflow?.workflow.form;
+
+ if (!form || typeof form !== 'object' || !('elements' in form) || !('rootElementId' in form)) {
+ return null;
+ }
+
+ return form as unknown as BuilderForm;
+};
+
+const getWorkflowNodes = (workflow: WorkflowResponse | undefined): WorkflowNodeLike[] => {
+ return (workflow?.workflow.nodes ?? []) as WorkflowNodeLike[];
+};
+
+const buildFormFromExposedFields = (
+ workflow: WorkflowResponse | undefined,
+ templates: Templates
+): BuilderForm | null => {
+ const exposedFields = (workflow?.workflow.exposedFields ?? []) as ExposedFieldLike[];
+
+ if (exposedFields.length === 0) {
+ return null;
+ }
+
+ const nodes = getWorkflowNodes(workflow);
+ const form = getDefaultForm();
+
+ for (const { nodeId, fieldName } of [...exposedFields].reverse()) {
+ if (!nodeId || !fieldName) {
+ continue;
+ }
+
+ const node = nodes.find((candidate) => candidate.data?.id === nodeId);
+ const nodeType = node?.data?.type;
+ if (!nodeType) {
+ continue;
+ }
+
+ const fieldTemplate = templates[nodeType]?.inputs[fieldName];
+ if (!fieldTemplate) {
+ continue;
+ }
+
+ const element = buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type);
+ element.data.showDescription = false;
+ addElement({
+ form,
+ element,
+ parentId: form.rootElementId,
+ index: 0,
+ });
+ }
+
+ return form;
+};
+
+export const getRenderableWorkflowForm = (
+ workflow: WorkflowResponse | undefined,
+ templates: Templates
+): BuilderForm | null => {
+ const storedForm = getStoredForm(workflow);
+
+ if (storedForm && validateFormStructure(storedForm) && !getIsFormEmpty(storedForm)) {
+ return storedForm;
+ }
+
+ const fallbackForm = buildFormFromExposedFields(workflow, templates);
+ if (fallbackForm && !getIsFormEmpty(fallbackForm)) {
+ return fallbackForm;
+ }
+
+ if (storedForm && validateFormStructure(storedForm)) {
+ return storedForm;
+ }
+
+ return null;
+};
+
+export const getSavedWorkflowFormFieldData = (
+ workflow: WorkflowResponse | undefined,
+ templates: Templates,
+ element: NodeFieldElement
+): SavedWorkflowFormFieldData => {
+ const { nodeId, fieldName } = element.data.fieldIdentifier;
+ const node = getWorkflowNodes(workflow).find((candidate) => candidate.data?.id === nodeId);
+ const nodeType = node?.data?.type;
+ const field = node?.data?.inputs?.[fieldName];
+ const fieldTemplate = nodeType ? templates[nodeType]?.inputs[fieldName] : undefined;
+
+ return {
+ label: field?.label || fieldTemplate?.title || fieldName,
+ description: fieldTemplate?.description || '',
+ typeName: fieldTemplate?.type.name ?? null,
+ isMissing: !node || !field || !fieldTemplate,
+ };
+};
+
+const getElementsInOrder = (form: BuilderForm): FormElement[] => {
+ const orderedElements: FormElement[] = [];
+
+ const visit = (elementId: string) => {
+ const element = form.elements[elementId];
+ if (!element) {
+ return;
+ }
+
+ orderedElements.push(element);
+ if (isContainerElement(element)) {
+ for (const childId of element.data.children) {
+ visit(childId);
+ }
+ }
+ };
+
+ visit(form.rootElementId);
+ return orderedElements;
+};
+
+const buildDynamicFieldName = (nodeId: string, fieldName: string): string => {
+ return `${CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX}${nodeId}::${fieldName}`;
+};
+
+const cloneDynamicFieldTemplate = ({
+ fieldName,
+ fieldTemplate,
+ label,
+ description,
+ uiOrder,
+}: {
+ fieldName: string;
+ fieldTemplate: FieldInputTemplate;
+ label: string;
+ description: string;
+ uiOrder: number;
+}): FieldInputTemplate => {
+ return {
+ ...fieldTemplate,
+ name: fieldName,
+ title: label,
+ description,
+ ui_order: uiOrder,
+ input: 'any',
+ ui_hidden: false,
+ } as FieldInputTemplate;
+};
+
+export const getSavedWorkflowDynamicFields = (
+ workflow: WorkflowResponse | undefined,
+ templates: Templates
+): SavedWorkflowDynamicField[] => {
+ const form = getRenderableWorkflowForm(workflow, templates);
+ if (!form) {
+ return [];
+ }
+
+ const nodes = getWorkflowNodes(workflow);
+ const dynamicFields: SavedWorkflowDynamicField[] = [];
+
+ for (const element of getElementsInOrder(form)) {
+ if (!('data' in element) || !('fieldIdentifier' in (element.data ?? {}))) {
+ continue;
+ }
+ if (!('type' in element) || element.type !== 'node-field') {
+ continue;
+ }
+
+ const { nodeId, fieldName } = element.data.fieldIdentifier;
+ const node = nodes.find((candidate) => candidate.data?.id === nodeId);
+ const nodeType = node?.data?.type;
+ const field = node?.data?.inputs?.[fieldName];
+ const fieldTemplate = nodeType ? templates[nodeType]?.inputs[fieldName] : undefined;
+
+ if (!field || !fieldTemplate || !isStatefulFieldType(fieldTemplate.type)) {
+ continue;
+ }
+
+ const dynamicFieldName = buildDynamicFieldName(nodeId, fieldName);
+ const label = field.label || fieldTemplate.title || fieldName;
+ const description = field.description || fieldTemplate.description || '';
+
+ dynamicFields.push({
+ fieldName: dynamicFieldName,
+ fieldTemplate: cloneDynamicFieldTemplate({
+ fieldName: dynamicFieldName,
+ fieldTemplate,
+ label,
+ description,
+ uiOrder: dynamicFields.length,
+ }),
+ label,
+ description,
+ settings: element.data.settings,
+ });
+ }
+
+ return dynamicFields;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index bdab6c1ae36..7ef011929e8 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -85,6 +85,7 @@ import {
isNodeFieldElement,
isTextElement,
} from 'features/nodes/types/workflow';
+import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance';
import { atom, computed } from 'nanostores';
import type { MouseEvent } from 'react';
import type { UndoableOptions } from 'redux-undo';
@@ -93,6 +94,8 @@ import type { z } from 'zod';
import type { PendingConnection, Templates } from './types';
+const CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX = 'saved_workflow_input::';
+
export const getInitialWorkflow = (): Omit => {
return {
name: '',
@@ -481,6 +484,46 @@ const slice = createSlice({
}
field.description = val || '';
},
+ callSavedWorkflowDynamicFieldsChanged: (
+ state,
+ action: PayloadAction<{
+ nodeId: string;
+ fields: Array<{
+ fieldName: string;
+ fieldTemplate: Parameters[1];
+ label: string;
+ description: string;
+ }>;
+ }>
+ ) => {
+ const { nodeId, fields } = action.payload;
+ const node = state.nodes.find((n) => n.id === nodeId);
+ if (!isInvocationNode(node) || node.data.type !== 'call_saved_workflow') {
+ return;
+ }
+
+ const nextFieldNames = new Set(fields.map((field) => field.fieldName));
+
+ for (const fieldName of Object.keys(node.data.inputs)) {
+ if (fieldName.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX) && !nextFieldNames.has(fieldName)) {
+ delete node.data.inputs[fieldName];
+ }
+ }
+
+ for (const { fieldName, fieldTemplate, label, description } of fields) {
+ const existing = node.data.inputs[fieldName];
+ if (existing) {
+ existing.label = label;
+ existing.description = description;
+ continue;
+ }
+
+ const instance = buildFieldInputInstance(fieldName, fieldTemplate);
+ instance.label = label;
+ instance.description = description;
+ node.data.inputs[fieldName] = instance;
+ }
+ },
notesNodeValueChanged: (state, action: PayloadAction<{ nodeId: string; value: string }>) => {
const { nodeId, value } = action.payload;
const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId);
@@ -611,6 +654,7 @@ export const {
fieldStringGeneratorValueChanged,
fieldImageGeneratorValueChanged,
fieldDescriptionChanged,
+ callSavedWorkflowDynamicFieldsChanged,
nodeEditorReset,
nodeIsIntermediateChanged,
nodeIsOpenChanged,
@@ -855,3 +899,5 @@ export const getFormFieldInitialValues = (form: BuilderForm, nodes: NodesState['
return formFieldInitialValues;
};
+
+export { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX };
From 6218863545325153b19d83a02f5c13e2f186bc5d Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 20:35:27 -0500
Subject: [PATCH 022/100] Seed and handle dynamic workflow fields
---
.../Invocation/CallSavedWorkflowNode.tsx | 75 ++++++++++--
.../callSavedWorkflowFormUtils.test.ts | 2 +
.../Invocation/callSavedWorkflowFormUtils.ts | 6 +-
.../features/nodes/store/nodesSlice.test.ts | 108 ++++++++++++++++++
.../src/features/nodes/store/nodesSlice.ts | 4 +-
5 files changed, 185 insertions(+), 10 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
index 16bdb140995..73b30cfade1 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
@@ -1,7 +1,9 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Badge, Flex, Grid, GridItem, Text } from '@invoke-ai/ui-library';
+import { Badge, Box, Flex, Grid, GridItem, Text, Tooltip } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
+import { Handle, Position } from '@xyflow/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { FloatFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput';
import { FloatFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider';
@@ -27,6 +29,7 @@ import InvocationNodeFooter from 'features/nodes/components/flow/nodes/Invocatio
import InvocationNodeHeader from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeHeader';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
+import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
import { $templates, callSavedWorkflowDynamicFieldsChanged } from 'features/nodes/store/nodesSlice';
import type {
@@ -61,12 +64,14 @@ import {
isEnumFieldInputInstance,
isFloatFieldInputInstance,
isIntegerFieldInputInstance,
+ isModelFieldType,
isModelIdentifierFieldInputInstance,
isSchedulerFieldInputInstance,
isStringFieldInputInstance,
isStylePresetFieldInputInstance,
} from 'features/nodes/types/field';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
+import type { CSSProperties } from 'react';
import { memo, useEffect, useMemo } from 'react';
import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
@@ -88,22 +93,55 @@ const bodySx: SystemStyleObject = {
};
const dynamicFieldSx: SystemStyleObject = {
- px: 2,
- py: 1,
- gap: 1,
+ py: 0.5,
flexDir: 'column',
w: 'full',
+ position: 'relative',
};
const fieldBodySx: SystemStyleObject = {
+ ml: 2,
borderWidth: '1px',
borderRadius: 'base',
borderColor: 'base.700',
px: 2,
py: 1.5,
minH: 12,
+ gap: 1,
+ flexDir: 'column',
+};
+
+const handleSx: SystemStyleObject = {
+ position: 'relative',
+ width: 'full',
+ height: 'full',
+ borderStyle: 'solid',
+ borderWidth: 4,
+ pointerEvents: 'none',
+ '&[data-cardinality="SINGLE"]': {
+ borderWidth: 0,
+ },
+ borderRadius: '100%',
+ '&[data-is-model-field="true"], &[data-is-batch-field="true"]': {
+ borderRadius: 4,
+ },
+ '&[data-is-batch-field="true"]': {
+ transform: 'rotate(45deg)',
+ },
};
+const handleStyles = {
+ position: 'absolute',
+ width: '1rem',
+ height: '1rem',
+ zIndex: 1,
+ background: 'none',
+ border: 'none',
+ insetInlineStart: '-0.5rem',
+ top: '50%',
+ transform: 'translateY(-50%)',
+} satisfies CSSProperties;
+
type Props = {
nodeId: string;
isOpen: boolean;
@@ -185,10 +223,11 @@ const DynamicFieldRow = memo(
return (
-
- {instance.label || field.fieldTemplate.title}
-
+
+
+ {instance.label || field.fieldTemplate.title}
+
{
+ const fieldTypeName = useFieldTypeName(template.type);
+ const fieldColor = useMemo(() => getFieldColor(template.type), [template.type]);
+ const isModelField = useMemo(() => isModelFieldType(template.type), [template.type]);
+
+ return (
+
+
+
+
+
+ );
+});
+DynamicInputFieldHandle.displayName = 'DynamicInputFieldHandle';
+
const DynamicFieldInputRenderer = memo(
({
nodeId,
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
index 4751ceff6e7..9ba894521b9 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
@@ -181,5 +181,7 @@ describe('callSavedWorkflowFormUtils', () => {
expect(dynamicFields[1]?.fieldTemplate.title).toBe('B');
expect(dynamicFields[0]?.fieldTemplate.name).toBe(dynamicFields[0]?.fieldName);
expect(dynamicFields[1]?.fieldTemplate.ui_order).toBe(1);
+ expect(dynamicFields[0]?.initialValue).toBe(1);
+ expect(dynamicFields[1]?.initialValue).toBe(2);
});
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
index 58a137f5389..00907da3c7b 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
@@ -1,7 +1,7 @@
import { addElement, getIsFormEmpty } from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX } from 'features/nodes/store/nodesSlice';
import type { Templates } from 'features/nodes/store/types';
-import type { FieldInputTemplate } from 'features/nodes/types/field';
+import type { FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import { isStatefulFieldType } from 'features/nodes/types/field';
import {
type BuilderForm,
@@ -21,7 +21,7 @@ type WorkflowNodeLike = {
data?: {
id?: string;
type?: string;
- inputs?: Record;
+ inputs?: Record;
};
};
@@ -42,6 +42,7 @@ type SavedWorkflowDynamicField = {
fieldTemplate: FieldInputTemplate;
label: string;
description: string;
+ initialValue: StatefulFieldValue;
settings: NodeFieldElement['data']['settings'];
};
@@ -236,6 +237,7 @@ export const getSavedWorkflowDynamicFields = (
}),
label,
description,
+ initialValue: field.value,
settings: element.data.settings,
});
}
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
new file mode 100644
index 00000000000..f5725c0264c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
@@ -0,0 +1,108 @@
+import { buildNode, templates } from 'features/nodes/store/util/testUtils';
+import type { IntegerFieldInputTemplate } from 'features/nodes/types/field';
+import { describe, expect, it } from 'vitest';
+
+import { callSavedWorkflowDynamicFieldsChanged, fieldIntegerValueChanged, nodesSliceConfig } from './nodesSlice';
+
+const callSavedWorkflowTemplate = templates.call_saved_workflow;
+const addTemplate = templates.add;
+
+if (!callSavedWorkflowTemplate || !addTemplate || !addTemplate.inputs.a) {
+ throw new Error('Expected saved workflow and add templates');
+}
+const addIntegerInputTemplate = addTemplate.inputs.a as IntegerFieldInputTemplate;
+
+const buildDynamicIntegerTemplate = (fieldName: string): IntegerFieldInputTemplate => ({
+ ...addIntegerInputTemplate,
+ name: fieldName,
+ title: 'Left Addend',
+ input: 'any' as const,
+});
+
+describe('callSavedWorkflowDynamicFieldsChanged', () => {
+ it('seeds new dynamic fields with the source workflow values', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const node = buildNode(callSavedWorkflowTemplate);
+ state.nodes.push(node);
+
+ const nextState = nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [
+ {
+ fieldName: 'saved_workflow_input::node-1::a',
+ fieldTemplate: buildDynamicIntegerTemplate('saved_workflow_input::node-1::a'),
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ })
+ );
+
+ const dynamicField = nextState.nodes[0];
+ if (!dynamicField || dynamicField.type !== 'invocation') {
+ throw new Error('Expected invocation node');
+ }
+
+ expect(dynamicField.data.inputs['saved_workflow_input::node-1::a']?.value).toBe(23);
+ expect(dynamicField.data.inputs['saved_workflow_input::node-1::a']?.label).toBe('Left Addend');
+ });
+
+ it('preserves existing dynamic field values on resync', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const node = buildNode(callSavedWorkflowTemplate);
+ state.nodes.push(node);
+
+ const fieldName = 'saved_workflow_input::node-1::a';
+
+ let nextState = nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [
+ {
+ fieldName,
+ fieldTemplate: buildDynamicIntegerTemplate(fieldName),
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ })
+ );
+
+ nextState = nodesSliceConfig.slice.reducer(
+ nextState,
+ fieldIntegerValueChanged({
+ nodeId: node.id,
+ fieldName,
+ value: 99,
+ })
+ );
+
+ nextState = nodesSliceConfig.slice.reducer(
+ nextState,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [
+ {
+ fieldName,
+ fieldTemplate: buildDynamicIntegerTemplate(fieldName),
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ })
+ );
+
+ const resyncedNode = nextState.nodes[0];
+ if (!resyncedNode || resyncedNode.type !== 'invocation') {
+ throw new Error('Expected invocation node');
+ }
+
+ expect(resyncedNode.data.inputs[fieldName]?.value).toBe(99);
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index 7ef011929e8..92e69ab3567 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -493,6 +493,7 @@ const slice = createSlice({
fieldTemplate: Parameters[1];
label: string;
description: string;
+ initialValue: StatefulFieldValue;
}>;
}>
) => {
@@ -510,7 +511,7 @@ const slice = createSlice({
}
}
- for (const { fieldName, fieldTemplate, label, description } of fields) {
+ for (const { fieldName, fieldTemplate, label, description, initialValue } of fields) {
const existing = node.data.inputs[fieldName];
if (existing) {
existing.label = label;
@@ -521,6 +522,7 @@ const slice = createSlice({
const instance = buildFieldInputInstance(fieldName, fieldTemplate);
instance.label = label;
instance.description = description;
+ instance.value = initialValue;
node.data.inputs[fieldName] = instance;
}
},
From ae8777fc8481fa08f053d8d0bc80baded1ba511d Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 20:46:34 -0500
Subject: [PATCH 023/100] Align dynamic workflow fields with node styling
---
.../Invocation/CallSavedWorkflowNode.tsx | 41 +++++++++----------
1 file changed, 20 insertions(+), 21 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
index 73b30cfade1..d39fa3c7ac0 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
@@ -10,6 +10,7 @@ import { FloatFieldInputAndSlider } from 'features/nodes/components/flow/nodes/I
import { FloatFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldSlider';
import { InputFieldEditModeNodes } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
+import { InputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper';
import BoardFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/BoardFieldInputComponent';
import BooleanFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/BooleanFieldInputComponent';
import ColorFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ColorFieldInputComponent';
@@ -93,22 +94,18 @@ const bodySx: SystemStyleObject = {
};
const dynamicFieldSx: SystemStyleObject = {
- py: 0.5,
+ px: 2,
flexDir: 'column',
w: 'full',
- position: 'relative',
};
const fieldBodySx: SystemStyleObject = {
- ml: 2,
- borderWidth: '1px',
- borderRadius: 'base',
- borderColor: 'base.700',
px: 2,
- py: 1.5,
- minH: 12,
+ py: 1,
gap: 1,
flexDir: 'column',
+ w: 'full',
+ pointerEvents: 'auto',
};
const handleSx: SystemStyleObject = {
@@ -222,20 +219,22 @@ const DynamicFieldRow = memo(
}
return (
-
-
-
-
- {instance.label || field.fieldTemplate.title}
-
-
+
+
+
+
+ {instance.label || field.fieldTemplate.title}
+
+
+
-
+
+
);
}
);
From c48732ee54aa280779ec0988d63d6ca13fbeb59e Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 8 Apr 2026 21:23:01 -0500
Subject: [PATCH 024/100] Use standard templates for dynamic workflow fields
---
.../Invocation/CallSavedWorkflowNode.tsx | 353 ++----------------
.../flow/nodes/Invocation/context.tsx | 27 +-
.../fields/InputFieldEditModeNodes.tsx | 10 +-
.../features/nodes/store/nodesSlice.test.ts | 42 +++
.../src/features/nodes/store/nodesSlice.ts | 2 +
.../nodes/store/util/fieldValidators.test.ts | 52 +++
.../nodes/store/util/fieldValidators.ts | 11 +-
.../store/util/getFirstValidConnection.ts | 12 +-
.../store/util/validateConnection.test.ts | 52 ++-
.../nodes/store/util/validateConnection.ts | 12 +-
.../src/features/nodes/types/invocation.ts | 26 ++
.../nodes/util/graph/buildNodesGraph.test.ts | 64 ++++
.../nodes/util/graph/buildNodesGraph.ts | 4 +-
.../nodes/util/node/buildInvocationNode.ts | 1 +
.../nodes/util/workflow/graphToWorkflow.ts | 1 +
.../util/workflow/validateWorkflow.test.ts | 2 +
.../nodes/util/workflow/validateWorkflow.ts | 15 +-
17 files changed, 330 insertions(+), 356 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.test.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
index d39fa3c7ac0..913612c3d5e 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
@@ -1,78 +1,18 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Badge, Box, Flex, Grid, GridItem, Text, Tooltip } from '@invoke-ai/ui-library';
+import { Badge, Flex, Grid, GridItem } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
-import { Handle, Position } from '@xyflow/react';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
-import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
-import { FloatFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput';
-import { FloatFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider';
-import { FloatFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldSlider';
+import { useAppDispatch } from 'app/store/storeHooks';
import { InputFieldEditModeNodes } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
-import { InputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper';
-import BoardFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/BoardFieldInputComponent';
-import BooleanFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/BooleanFieldInputComponent';
-import ColorFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ColorFieldInputComponent';
-import EnumFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/EnumFieldInputComponent';
-import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelIdentifierFieldInputComponent';
-import SchedulerFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/SchedulerFieldInputComponent';
-import StylePresetFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StylePresetFieldInputComponent';
-import { IntegerFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput';
-import { IntegerFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider';
-import { IntegerFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider';
import { OutputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate';
import { OutputFieldNodesEditorView } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView';
-import { StringFieldDropdown } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldDropdown';
-import { StringFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldInput';
-import { StringFieldTextarea } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldTextarea';
import InvocationNodeFooter from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter';
import InvocationNodeHeader from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeHeader';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
-import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
import { $templates, callSavedWorkflowDynamicFieldsChanged } from 'features/nodes/store/nodesSlice';
-import type {
- BoardFieldInputInstance,
- BoardFieldInputTemplate,
- BooleanFieldInputInstance,
- BooleanFieldInputTemplate,
- ColorFieldInputInstance,
- ColorFieldInputTemplate,
- EnumFieldInputInstance,
- EnumFieldInputTemplate,
- FieldInputInstance,
- FieldInputTemplate,
- FloatFieldInputInstance,
- FloatFieldInputTemplate,
- IntegerFieldInputInstance,
- IntegerFieldInputTemplate,
- ModelIdentifierFieldInputInstance,
- ModelIdentifierFieldInputTemplate,
- SavedWorkflowFieldInputInstance,
- SchedulerFieldInputInstance,
- SchedulerFieldInputTemplate,
- StringFieldInputInstance,
- StringFieldInputTemplate,
- StylePresetFieldInputInstance,
- StylePresetFieldInputTemplate,
-} from 'features/nodes/types/field';
-import {
- isBoardFieldInputInstance,
- isBooleanFieldInputInstance,
- isColorFieldInputInstance,
- isEnumFieldInputInstance,
- isFloatFieldInputInstance,
- isIntegerFieldInputInstance,
- isModelFieldType,
- isModelIdentifierFieldInputInstance,
- isSchedulerFieldInputInstance,
- isStringFieldInputInstance,
- isStylePresetFieldInputInstance,
-} from 'features/nodes/types/field';
-import type { NodeFieldElement } from 'features/nodes/types/workflow';
-import type { CSSProperties } from 'react';
+import type { SavedWorkflowFieldInputInstance } from 'features/nodes/types/field';
import { memo, useEffect, useMemo } from 'react';
import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
@@ -94,51 +34,9 @@ const bodySx: SystemStyleObject = {
};
const dynamicFieldSx: SystemStyleObject = {
- px: 2,
- flexDir: 'column',
w: 'full',
};
-const fieldBodySx: SystemStyleObject = {
- px: 2,
- py: 1,
- gap: 1,
- flexDir: 'column',
- w: 'full',
- pointerEvents: 'auto',
-};
-
-const handleSx: SystemStyleObject = {
- position: 'relative',
- width: 'full',
- height: 'full',
- borderStyle: 'solid',
- borderWidth: 4,
- pointerEvents: 'none',
- '&[data-cardinality="SINGLE"]': {
- borderWidth: 0,
- },
- borderRadius: '100%',
- '&[data-is-model-field="true"], &[data-is-batch-field="true"]': {
- borderRadius: 4,
- },
- '&[data-is-batch-field="true"]': {
- transform: 'rotate(45deg)',
- },
-};
-
-const handleStyles = {
- position: 'absolute',
- width: '1rem',
- height: '1rem',
- zIndex: 1,
- background: 'none',
- border: 'none',
- insetInlineStart: '-0.5rem',
- top: '50%',
- transform: 'translateY(-50%)',
-} satisfies CSSProperties;
-
type Props = {
nodeId: string;
isOpen: boolean;
@@ -200,7 +98,12 @@ const DynamicFieldsSection = memo(
return (
<>
{fields.map((field) => (
-
+
))}
>
);
@@ -209,243 +112,33 @@ const DynamicFieldsSection = memo(
DynamicFieldsSection.displayName = 'DynamicFieldsSection';
const DynamicFieldRow = memo(
- ({ nodeId, field }: { nodeId: string; field: ReturnType[number] }) => {
- const ctx = useInvocationNodeContext();
- const selector = useMemo(() => ctx.buildSelectInputFieldSafe(field.fieldName), [ctx, field.fieldName]);
- const instance = useAppSelector(selector);
-
- if (!instance) {
- return null;
- }
-
- return (
-
-
-
-
- {instance.label || field.fieldTemplate.title}
-
-
-
-
-
-
- );
- }
-);
-DynamicFieldRow.displayName = 'DynamicFieldRow';
-
-const DynamicInputFieldHandle = memo(({ fieldName, template }: { fieldName: string; template: FieldInputTemplate }) => {
- const fieldTypeName = useFieldTypeName(template.type);
- const fieldColor = useMemo(() => getFieldColor(template.type), [template.type]);
- const isModelField = useMemo(() => isModelFieldType(template.type), [template.type]);
-
- return (
-
-
-
-
-
- );
-});
-DynamicInputFieldHandle.displayName = 'DynamicInputFieldHandle';
-
-const DynamicFieldInputRenderer = memo(
({
nodeId,
- instance,
- template,
+ fieldName,
settings,
}: {
nodeId: string;
- instance: FieldInputInstance;
- template: FieldInputTemplate;
- settings: NodeFieldElement['data']['settings'];
+ fieldName: string;
+ settings: ReturnType[number]['settings'];
}) => {
- if (template.type.name === 'StringField' && isStringFieldInputInstance(instance)) {
- if (settings?.type === 'string-field-config' && settings.component === 'textarea') {
- return (
-
- );
- }
- if (settings?.type === 'string-field-config' && settings.component === 'dropdown') {
- return (
-
- );
- }
- return (
-
- );
- }
-
- if (template.type.name === 'IntegerField' && isIntegerFieldInputInstance(instance)) {
- if (settings?.type === 'integer-field-config' && settings.component === 'slider') {
- return (
-
- );
- }
- if (settings?.type === 'integer-field-config' && settings.component === 'number-input-and-slider') {
- return (
-
- );
- }
- return (
-
- );
- }
-
- if (template.type.name === 'FloatField' && isFloatFieldInputInstance(instance)) {
- if (settings?.type === 'float-field-config' && settings.component === 'slider') {
- return (
-
- );
- }
- if (settings?.type === 'float-field-config' && settings.component === 'number-input-and-slider') {
- return (
-
- );
- }
- return (
-
- );
- }
-
- if (template.type.name === 'BooleanField' && isBooleanFieldInputInstance(instance)) {
- return (
-
- );
- }
-
- if (template.type.name === 'EnumField' && isEnumFieldInputInstance(instance)) {
- return (
-
- );
- }
-
- if (template.type.name === 'BoardField' && isBoardFieldInputInstance(instance)) {
- return (
-
- );
- }
-
- if (template.type.name === 'ModelIdentifierField' && isModelIdentifierFieldInputInstance(instance)) {
- return (
-
- );
- }
-
- if (template.type.name === 'SchedulerField' && isSchedulerFieldInputInstance(instance)) {
- return (
-
- );
- }
-
- if (template.type.name === 'ColorField' && isColorFieldInputInstance(instance)) {
- return (
-
- );
- }
-
- if (template.type.name === 'StylePresetField' && isStylePresetFieldInputInstance(instance)) {
- return (
-
- );
- }
-
return (
-
- Unsupported dynamic field type: {template.type.name}
-
+
+
+
+
+
);
}
);
-DynamicFieldInputRenderer.displayName = 'DynamicFieldInputRenderer';
+DynamicFieldRow.displayName = 'DynamicFieldRow';
const OutputFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useOutputFieldNames();
+
+ if (fieldNames.length === 0) {
+ return null;
+ }
+
return (
<>
{fieldNames.map((fieldName, i) => (
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/context.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/context.tsx
index 8618511d77d..af0a9fe4e95 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/context.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/context.tsx
@@ -4,7 +4,12 @@ import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectEdges, selectNodeFieldElements, selectNodes } from 'features/nodes/store/selectors';
-import type { InvocationNode, InvocationTemplate } from 'features/nodes/types/invocation';
+import {
+ getInvocationNodeInputTemplate,
+ getInvocationNodeTemplateWithDynamicInputs,
+ type InvocationNode,
+ type InvocationTemplate,
+} from 'features/nodes/types/invocation';
import { getNeedsUpdate } from 'features/nodes/util/node/nodeUpdate';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useContext, useMemo } from 'react';
@@ -81,8 +86,12 @@ export const InvocationNodeContextProvider = memo(({ nodeId, children }: PropsWi
})
);
const selectNodeTemplateSafe = getSelectorFromCache(cache, 'selectNodeTemplateSafe', () =>
- createSelector(selectNodeTypeSafe, (type) => {
- return type ? (templates[type] ?? null) : null;
+ createSelector(selectNodeDataSafe, (data) => {
+ if (!data) {
+ return null;
+ }
+ const template = templates[data.type];
+ return template ? getInvocationNodeTemplateWithDynamicInputs(data, template) : null;
})
);
const selectNodeInputsSafe = getSelectorFromCache(cache, 'selectNodeInputsSafe', () =>
@@ -129,12 +138,12 @@ export const InvocationNodeContextProvider = memo(({ nodeId, children }: PropsWi
})
);
const selectNodeTemplateOrThrow = getSelectorFromCache(cache, 'selectNodeTemplateOrThrow', () =>
- createSelector(selectNodeTypeOrThrow, (type) => {
- const template = templates[type];
+ createSelector(selectNodeDataOrThrow, (data) => {
+ const template = templates[data.type];
if (template === undefined) {
- throw new Error(`Cannot find template for node with id ${nodeId} with type ${type}`);
+ throw new Error(`Cannot find template for node with id ${nodeId} with type ${data.type}`);
}
- return template;
+ return getInvocationNodeTemplateWithDynamicInputs(data, template);
})
);
const selectNodeInputsOrThrow = getSelectorFromCache(cache, 'selectNodeInputsOrThrow', () =>
@@ -154,8 +163,8 @@ export const InvocationNodeContextProvider = memo(({ nodeId, children }: PropsWi
);
const buildSelectInputFieldTemplateOrThrow = (fieldName: string) =>
getSelectorFromCache(cache, `buildSelectInputFieldTemplateOrThrow-${fieldName}`, () =>
- createSelector(selectNodeTemplateOrThrow, (template) => {
- const fieldTemplate = template.inputs[fieldName];
+ createSelector(selectNodeDataOrThrow, selectNodeTemplateOrThrow, (data, template) => {
+ const fieldTemplate = getInvocationNodeInputTemplate(data, template, fieldName);
if (fieldTemplate === undefined) {
throw new Error(`Cannot find input field template with name ${fieldName} in node ${nodeId}`);
}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx
index 1597f4ede16..60ce93fa619 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx
@@ -9,6 +9,7 @@ import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInva
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
+import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo, useRef } from 'react';
import { InputFieldAddRemoveFormRoot } from './InputFieldAddRemoveFormRoot';
@@ -19,9 +20,10 @@ import { InputFieldWrapper } from './InputFieldWrapper';
interface Props {
nodeId: string;
fieldName: string;
+ settings?: NodeFieldElement['data']['settings'];
}
-export const InputFieldEditModeNodes = memo(({ nodeId, fieldName }: Props) => {
+export const InputFieldEditModeNodes = memo(({ nodeId, fieldName, settings }: Props) => {
const fieldTemplate = useInputFieldTemplateOrThrow(fieldName);
const isInvalid = useInputFieldIsInvalid(fieldName);
const isConnected = useInputFieldIsConnected(fieldName);
@@ -45,6 +47,7 @@ export const InputFieldEditModeNodes = memo(({ nodeId, fieldName }: Props) => {
isInvalid={isInvalid}
isConnected={isConnected}
fieldTemplate={fieldTemplate}
+ settings={settings}
/>
);
});
@@ -57,6 +60,7 @@ type CommonProps = {
isInvalid: boolean;
isConnected: boolean;
fieldTemplate: FieldInputTemplate;
+ settings?: NodeFieldElement['data']['settings'];
};
const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid }: CommonProps) => {
@@ -96,7 +100,7 @@ const directFieldSx: SystemStyleObject = {
},
};
-const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemplate }: CommonProps) => {
+const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemplate, settings }: CommonProps) => {
const draggableRef = useRef(null);
const dragHandleRef = useRef(null);
@@ -116,7 +120,7 @@ const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemp
-
+
{fieldTemplate.input !== 'direct' && }
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
index f5725c0264c..1b1edc086a5 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
@@ -48,6 +48,7 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
expect(dynamicField.data.inputs['saved_workflow_input::node-1::a']?.value).toBe(23);
expect(dynamicField.data.inputs['saved_workflow_input::node-1::a']?.label).toBe('Left Addend');
+ expect(dynamicField.data.dynamicInputTemplates['saved_workflow_input::node-1::a']?.title).toBe('Left Addend');
});
it('preserves existing dynamic field values on resync', () => {
@@ -104,5 +105,46 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
}
expect(resyncedNode.data.inputs[fieldName]?.value).toBe(99);
+ expect(resyncedNode.data.dynamicInputTemplates[fieldName]?.name).toBe(fieldName);
+ });
+
+ it('removes stale dynamic field templates when the selected workflow fields change', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const node = buildNode(callSavedWorkflowTemplate);
+ state.nodes.push(node);
+
+ const fieldName = 'saved_workflow_input::node-1::a';
+
+ let nextState = nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [
+ {
+ fieldName,
+ fieldTemplate: buildDynamicIntegerTemplate(fieldName),
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ })
+ );
+
+ nextState = nodesSliceConfig.slice.reducer(
+ nextState,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [],
+ })
+ );
+
+ const resyncedNode = nextState.nodes[0];
+ if (!resyncedNode || resyncedNode.type !== 'invocation') {
+ throw new Error('Expected invocation node');
+ }
+
+ expect(resyncedNode.data.inputs[fieldName]).toBeUndefined();
+ expect(resyncedNode.data.dynamicInputTemplates[fieldName]).toBeUndefined();
});
});
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index 92e69ab3567..a50dad2424b 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -508,10 +508,12 @@ const slice = createSlice({
for (const fieldName of Object.keys(node.data.inputs)) {
if (fieldName.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX) && !nextFieldNames.has(fieldName)) {
delete node.data.inputs[fieldName];
+ delete node.data.dynamicInputTemplates[fieldName];
}
}
for (const { fieldName, fieldTemplate, label, description, initialValue } of fields) {
+ node.data.dynamicInputTemplates[fieldName] = fieldTemplate;
const existing = node.data.inputs[fieldName];
if (existing) {
existing.label = label;
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.test.ts
new file mode 100644
index 00000000000..87271c627c9
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.test.ts
@@ -0,0 +1,52 @@
+import { callSavedWorkflowDynamicFieldsChanged, nodesSliceConfig } from 'features/nodes/store/nodesSlice';
+import { buildNode, templates } from 'features/nodes/store/util/testUtils';
+import type { IntegerFieldInputTemplate } from 'features/nodes/types/field';
+import { describe, expect, it } from 'vitest';
+
+import { getInvocationNodeErrors } from './fieldValidators';
+
+const callSavedWorkflowTemplate = templates.call_saved_workflow;
+const addTemplate = templates.add;
+
+if (!callSavedWorkflowTemplate || !addTemplate || !addTemplate.inputs.a) {
+ throw new Error('Expected saved workflow and add templates');
+}
+
+const addIntegerInputTemplate = addTemplate.inputs.a as IntegerFieldInputTemplate;
+
+const buildDynamicIntegerTemplate = (fieldName: string): IntegerFieldInputTemplate => ({
+ ...addIntegerInputTemplate,
+ name: fieldName,
+ title: 'Left Addend',
+ input: 'any',
+});
+
+describe('getInvocationNodeErrors', () => {
+ it('does not report missing field templates for dynamic saved workflow inputs', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const node = buildNode(callSavedWorkflowTemplate);
+ state.nodes.push(node);
+
+ const nextState = nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [
+ {
+ fieldName: 'saved_workflow_input::node-1::a',
+ fieldTemplate: buildDynamicIntegerTemplate('saved_workflow_input::node-1::a'),
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ })
+ );
+
+ const errors = getInvocationNodeErrors(node.id, templates, nextState);
+
+ expect(
+ errors.find((error) => error.type === 'node-error' && error.issue === 'parameters.invoke.missingFieldTemplate')
+ ).toBeUndefined();
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts
index 85738b357c9..b3ac3424fdf 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts
@@ -36,7 +36,12 @@ import {
isStringFieldCollectionInputInstance,
isStringFieldCollectionInputTemplate,
} from 'features/nodes/types/field';
-import { type InvocationNode, type InvocationTemplate, isInvocationNode } from 'features/nodes/types/invocation';
+import {
+ getInvocationNodeInputTemplate,
+ type InvocationNode,
+ type InvocationTemplate,
+ isInvocationNode,
+} from 'features/nodes/types/invocation';
import { t } from 'i18next';
import { map } from 'nanostores';
import { useEffect } from 'react';
@@ -272,7 +277,7 @@ export const getInvocationNodeErrors = (
}
for (const [fieldName, field] of Object.entries(node.data.inputs)) {
- const fieldTemplate = nodeTemplate.inputs[fieldName];
+ const fieldTemplate = getInvocationNodeInputTemplate(node.data, nodeTemplate, fieldName);
if (!fieldTemplate) {
errors.push({ type: 'node-error', nodeId, issue: t('parameters.invoke.missingFieldTemplate') });
@@ -307,7 +312,7 @@ const syncNodeErrors = (nodesState: NodesState, templates: Templates) => {
}
for (const [fieldName, field] of Object.entries(node.data.inputs)) {
- const fieldTemplate = nodeTemplate.inputs[fieldName];
+ const fieldTemplate = getInvocationNodeInputTemplate(node.data, nodeTemplate, fieldName);
if (!fieldTemplate) {
errors.push({ type: 'node-error', nodeId: node.id, issue: t('parameters.invoke.missingFieldTemplate') });
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts
index 0b5aa17e171..2835269bb11 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts
@@ -3,7 +3,12 @@ import { map } from 'es-toolkit/compat';
import type { Templates } from 'features/nodes/store/types';
import { validateConnection } from 'features/nodes/store/util/validateConnection';
import type { FieldInputTemplate, FieldOutputTemplate } from 'features/nodes/types/field';
-import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
+import {
+ type AnyEdge,
+ type AnyNode,
+ getInvocationNodeInputTemplate,
+ isInvocationNode,
+} from 'features/nodes/types/invocation';
/**
*
@@ -132,8 +137,11 @@ export const getSourceCandidateFields = (
if (!sourceTemplate || !targetTemplate) {
return [];
}
+ if (!isInvocationNode(targetNode)) {
+ return [];
+ }
- const targetField = targetTemplate.inputs[targetHandle];
+ const targetField = getInvocationNodeInputTemplate(targetNode.data, targetTemplate, targetHandle);
if (!targetField) {
return [];
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
index 88eae8484fd..a7c6284c189 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
@@ -1,9 +1,21 @@
import { deepClone } from 'common/util/deepClone';
import { set } from 'es-toolkit/compat';
+import { callSavedWorkflowDynamicFieldsChanged, nodesSliceConfig } from 'features/nodes/store/nodesSlice';
+import type { IntegerFieldInputTemplate } from 'features/nodes/types/field';
import type { InvocationTemplate } from 'features/nodes/types/invocation';
import { describe, expect, it } from 'vitest';
-import { add, buildEdge, buildNode, collect, img_resize, main_model_loader, sub, templates } from './testUtils';
+import {
+ add,
+ buildEdge,
+ buildNode,
+ call_saved_workflow,
+ collect,
+ img_resize,
+ main_model_loader,
+ sub,
+ templates,
+} from './testUtils';
import { validateConnection } from './validateConnection';
const ifTemplate: InvocationTemplate = {
@@ -197,6 +209,44 @@ describe(validateConnection.name, () => {
});
});
+ it('accepts connections to dynamic saved workflow input fields', () => {
+ const addIntegerInputTemplate = add.inputs.a as IntegerFieldInputTemplate;
+ const state = nodesSliceConfig.getInitialState();
+ const sourceNode = buildNode(add);
+ const targetNode = buildNode(call_saved_workflow);
+ state.nodes.push(sourceNode, targetNode);
+
+ const nextState = nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: targetNode.id,
+ fields: [
+ {
+ fieldName: 'saved_workflow_input::node-1::a',
+ fieldTemplate: {
+ ...addIntegerInputTemplate,
+ name: 'saved_workflow_input::node-1::a',
+ title: 'Left Addend',
+ input: 'any',
+ },
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ })
+ );
+
+ const c = {
+ source: sourceNode.id,
+ sourceHandle: 'value',
+ target: targetNode.id,
+ targetHandle: 'saved_workflow_input::node-1::a',
+ };
+ const r = validateConnection(c, nextState.nodes, [], templates, null);
+ expect(r).toEqual(null);
+ });
+
describe('duplicate connections', () => {
const n1 = buildNode(add);
const n2 = buildNode(sub);
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts
index b342df064b2..d20c520a12f 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts
@@ -4,7 +4,12 @@ import { areTypesEqual } from 'features/nodes/store/util/areTypesEqual';
import { getCollectItemType } from 'features/nodes/store/util/getCollectItemType';
import { getHasCycles } from 'features/nodes/store/util/getHasCycles';
import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes';
-import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
+import {
+ type AnyEdge,
+ type AnyNode,
+ getInvocationNodeInputTemplate,
+ isInvocationNode,
+} from 'features/nodes/types/invocation';
import type { SetNonNullable } from 'type-fest';
type Connection = SetNonNullable;
@@ -102,13 +107,16 @@ export const validateConnection: ValidateConnectionFunc = (
if (!targetTemplate) {
return 'nodes.missingInvocationTemplate';
}
+ if (!isInvocationNode(targetNode)) {
+ return 'nodes.missingInvocationTemplate';
+ }
const sourceFieldTemplate = sourceTemplate.outputs[c.sourceHandle];
if (!sourceFieldTemplate) {
return 'nodes.missingFieldTemplate';
}
- const targetFieldTemplate = targetTemplate.inputs[c.targetHandle];
+ const targetFieldTemplate = getInvocationNodeInputTemplate(targetNode.data, targetTemplate, c.targetHandle);
if (!targetFieldTemplate) {
return 'nodes.missingFieldTemplate';
}
diff --git a/invokeai/frontend/web/src/features/nodes/types/invocation.ts b/invokeai/frontend/web/src/features/nodes/types/invocation.ts
index 8cd529deb7d..df820254f0e 100644
--- a/invokeai/frontend/web/src/features/nodes/types/invocation.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/invocation.ts
@@ -31,6 +31,7 @@ export const zInvocationNodeData = z.object({
notes: z.string(),
type: z.string().trim().min(1),
inputs: z.record(z.string(), zFieldInputInstance),
+ dynamicInputTemplates: z.record(z.string(), zFieldInputTemplate).default({}),
isOpen: z.boolean(),
isIntermediate: z.boolean(),
useCache: z.boolean(),
@@ -143,3 +144,28 @@ const isGeneratorNode = (node: InvocationNode) => isGeneratorNodeType(node.data.
export const isExecutableNode = (node: InvocationNode) => {
return !isBatchNode(node) && !isGeneratorNode(node);
};
+
+export const getInvocationNodeInputTemplate = (
+ nodeData: Pick & Partial>,
+ template: InvocationTemplate,
+ fieldName: string
+) => {
+ return nodeData.dynamicInputTemplates?.[fieldName] ?? template.inputs[fieldName];
+};
+
+export const getInvocationNodeTemplateWithDynamicInputs = (
+ nodeData: Pick & Partial>,
+ template: InvocationTemplate
+): InvocationTemplate => {
+ if (!nodeData.dynamicInputTemplates || Object.keys(nodeData.dynamicInputTemplates).length === 0) {
+ return template;
+ }
+
+ return {
+ ...template,
+ inputs: {
+ ...template.inputs,
+ ...nodeData.dynamicInputTemplates,
+ },
+ };
+};
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
new file mode 100644
index 00000000000..5d758f70f54
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
@@ -0,0 +1,64 @@
+import { callSavedWorkflowDynamicFieldsChanged, nodesSliceConfig } from 'features/nodes/store/nodesSlice';
+import { buildNode, templates } from 'features/nodes/store/util/testUtils';
+import type { IntegerFieldInputTemplate } from 'features/nodes/types/field';
+import { describe, expect, it } from 'vitest';
+
+import { buildNodesGraph } from './buildNodesGraph';
+
+const callSavedWorkflowTemplate = templates.call_saved_workflow;
+const addTemplate = templates.add;
+
+if (!callSavedWorkflowTemplate || !addTemplate || !addTemplate.inputs.a) {
+ throw new Error('Expected saved workflow and add templates');
+}
+
+const addIntegerInputTemplate = addTemplate.inputs.a as IntegerFieldInputTemplate;
+
+const buildDynamicIntegerTemplate = (fieldName: string): IntegerFieldInputTemplate => ({
+ ...addIntegerInputTemplate,
+ name: fieldName,
+ title: 'Left Addend',
+ input: 'any',
+});
+
+describe('buildNodesGraph', () => {
+ it('includes dynamic saved workflow inputs when templates are stored on the node', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const node = buildNode(callSavedWorkflowTemplate);
+ state.nodes.push(node);
+
+ const nextState = nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [
+ {
+ fieldName: 'saved_workflow_input::node-1::a',
+ fieldTemplate: buildDynamicIntegerTemplate('saved_workflow_input::node-1::a'),
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ })
+ );
+
+ const rootState = {
+ nodes: {
+ past: [],
+ future: [],
+ present: nextState,
+ },
+ gallery: {
+ autoAddBoardId: 'none',
+ },
+ } as never;
+
+ const graph = buildNodesGraph(rootState, templates);
+
+ expect(graph.nodes[node.id]).toMatchObject({
+ workflow_id: '',
+ ['saved_workflow_input::node-1::a']: 23,
+ });
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
index d83555e5580..6db22981ec9 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
@@ -7,7 +7,7 @@ import type { Templates } from 'features/nodes/store/types';
import type { BoardField } from 'features/nodes/types/common';
import type { BoardFieldInputInstance } from 'features/nodes/types/field';
import { isBoardFieldInputInstance, isBoardFieldInputTemplate } from 'features/nodes/types/field';
-import { isExecutableNode, isInvocationNode } from 'features/nodes/types/invocation';
+import { getInvocationNodeInputTemplate, isExecutableNode, isInvocationNode } from 'features/nodes/types/invocation';
import type { AnyInvocation, Graph } from 'services/api/types';
import { v4 as uuidv4 } from 'uuid';
@@ -58,7 +58,7 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require
const transformedInputs = reduce(
inputs,
(inputsAccumulator, input, name) => {
- const fieldTemplate = nodeTemplate.inputs[name];
+ const fieldTemplate = getInvocationNodeInputTemplate(data, nodeTemplate, name);
if (!fieldTemplate) {
log.warn({ id, name }, 'Field template not found!');
return inputsAccumulator;
diff --git a/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts b/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts
index 859e978b0fa..2af152e6bb8 100644
--- a/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts
@@ -40,6 +40,7 @@ export const buildInvocationNode = (position: XYPosition, template: InvocationTe
useCache: template.useCache,
nodePack: template.nodePack,
inputs,
+ dynamicInputTemplates: {},
},
};
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts
index 05efc26fea7..dd9813f8eb6 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts
@@ -84,6 +84,7 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor
version: template.version,
label: '',
notes: '',
+ dynamicInputTemplates: {},
isOpen: true,
isIntermediate: node.is_intermediate ?? false,
useCache: node.use_cache ?? true,
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
index 1442a3475e5..5e8aed52769 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
@@ -28,6 +28,7 @@ describe('validateWorkflow', () => {
version: '1.0.2',
label: '',
notes: '',
+ dynamicInputTemplates: {},
isOpen: true,
isIntermediate: true,
useCache: true,
@@ -58,6 +59,7 @@ describe('validateWorkflow', () => {
version: '1.2.2',
label: '',
notes: '',
+ dynamicInputTemplates: {},
isOpen: true,
isIntermediate: true,
useCache: true,
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts
index b86870d450e..63d4c1aafcb 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts
@@ -8,6 +8,7 @@ import {
isModelFieldType,
isModelIdentifierFieldInputInstance,
} from 'features/nodes/types/field';
+import { getInvocationNodeInputTemplate } from 'features/nodes/types/invocation';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import {
buildNodeFieldElement,
@@ -96,7 +97,7 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise id === nodeId);
- if (!node) {
+ if (!node || !isWorkflowInvocationNode(node)) {
continue;
}
const nodeTemplate = templates[node.data.type];
if (!nodeTemplate) {
continue;
}
- const fieldTemplate = nodeTemplate.inputs[fieldName];
+ const fieldTemplate = getInvocationNodeInputTemplate(node.data, nodeTemplate, fieldName);
if (!fieldTemplate) {
continue;
}
From 381da4d022f078e456a10982f44c000fae61039d Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Thu, 9 Apr 2026 06:45:25 -0500
Subject: [PATCH 025/100] Revert stale dynamic edge graph guard
---
.../web/src/features/nodes/util/graph/buildNodesGraph.test.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
index 5d758f70f54..622ed54dad2 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
@@ -40,6 +40,7 @@ describe('buildNodesGraph', () => {
initialValue: 23,
},
],
+ edgeIdsToRemove: [],
})
);
From 71389b6b9721da59da46dd81755474af99e29041 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Thu, 9 Apr 2026 07:22:22 -0500
Subject: [PATCH 026/100] Preserve inbound edges when compatbile with a new
selected workflow
---
.../Invocation/CallSavedWorkflowNode.tsx | 32 ++++++--
.../callSavedWorkflowFormUtils.test.ts | 82 ++++++++++++++++++-
.../Invocation/callSavedWorkflowFormUtils.ts | 51 ++++++++++++
.../features/nodes/store/nodesSlice.test.ts | 31 +++++++
.../src/features/nodes/store/nodesSlice.ts | 8 +-
.../nodes/store/util/fieldValidators.test.ts | 1 +
.../store/util/validateConnection.test.ts | 1 +
7 files changed, 199 insertions(+), 7 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
index 913612c3d5e..55a58e72a6b 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
@@ -1,7 +1,7 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Badge, Flex, Grid, GridItem } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
-import { useAppDispatch } from 'app/store/storeHooks';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InputFieldEditModeNodes } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { OutputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate';
@@ -12,11 +12,12 @@ import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstanc
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
import { $templates, callSavedWorkflowDynamicFieldsChanged } from 'features/nodes/store/nodesSlice';
+import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { SavedWorkflowFieldInputInstance } from 'features/nodes/types/field';
-import { memo, useEffect, useMemo } from 'react';
+import { memo, useEffect, useMemo, useRef } from 'react';
import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
-import { getSavedWorkflowDynamicFields } from './callSavedWorkflowFormUtils';
+import { getSavedWorkflowDynamicEdgeIdsToRemove, getSavedWorkflowDynamicFields } from './callSavedWorkflowFormUtils';
const bodySx: SystemStyleObject = {
flexDirection: 'column',
@@ -47,16 +48,37 @@ const CallSavedWorkflowNode = ({ nodeId, isOpen }: Props) => {
const workflowIdField = useInputFieldInstance('workflow_id');
const templates = useStore($templates);
const dispatch = useAppDispatch();
+ const nodesState = useAppSelector(selectNodesSlice);
const { data: workflow } = useGetWorkflowQuery(workflowIdField.value, {
skip: !workflowIdField.value,
});
const dynamicFields = useMemo(() => getSavedWorkflowDynamicFields(workflow, templates), [templates, workflow]);
+ const edgeIdsToRemove = useMemo(
+ () =>
+ getSavedWorkflowDynamicEdgeIdsToRemove({
+ nodeId,
+ fields: dynamicFields,
+ nodes: nodesState.nodes,
+ edges: nodesState.edges,
+ templates,
+ }),
+ [dynamicFields, nodeId, nodesState.edges, nodesState.nodes, templates]
+ );
+ const syncKey = useMemo(
+ () => JSON.stringify({ fields: dynamicFields, edgeIdsToRemove }),
+ [dynamicFields, edgeIdsToRemove]
+ );
+ const lastSyncKeyRef = useRef(null);
useEffect(() => {
- dispatch(callSavedWorkflowDynamicFieldsChanged({ nodeId, fields: dynamicFields }));
- }, [dispatch, dynamicFields, nodeId]);
+ if (lastSyncKeyRef.current === syncKey) {
+ return;
+ }
+ lastSyncKeyRef.current = syncKey;
+ dispatch(callSavedWorkflowDynamicFieldsChanged({ nodeId, fields: dynamicFields, edgeIdsToRemove }));
+ }, [dispatch, dynamicFields, edgeIdsToRemove, nodeId, syncKey]);
return (
<>
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
index 9ba894521b9..19ad20a486a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
@@ -1,4 +1,5 @@
-import { templates } from 'features/nodes/store/util/testUtils';
+import { buildEdge, buildNode, call_saved_workflow, templates } from 'features/nodes/store/util/testUtils';
+import type { BooleanFieldInputTemplate } from 'features/nodes/types/field';
import {
type BuilderForm,
buildHeading,
@@ -11,6 +12,7 @@ import { describe, expect, it } from 'vitest';
import {
getRenderableWorkflowForm,
+ getSavedWorkflowDynamicEdgeIdsToRemove,
getSavedWorkflowDynamicFields,
getSavedWorkflowFormFieldData,
} from './callSavedWorkflowFormUtils';
@@ -184,4 +186,82 @@ describe('callSavedWorkflowFormUtils', () => {
expect(dynamicFields[0]?.initialValue).toBe(1);
expect(dynamicFields[1]?.initialValue).toBe(2);
});
+
+ it('preserves inbound edges when the same exposed dynamic field remains compatible', () => {
+ const sourceNode = buildNode(addTemplate);
+ const targetNode = buildNode(call_saved_workflow);
+ const fields = getSavedWorkflowDynamicFields(
+ buildWorkflowResponse({
+ exposedFields: [{ nodeId: 'node-1', fieldName: 'a' }],
+ }),
+ templates
+ );
+
+ const edgeIdsToRemove = getSavedWorkflowDynamicEdgeIdsToRemove({
+ nodeId: targetNode.id,
+ fields,
+ nodes: [sourceNode, targetNode],
+ edges: [buildEdge(sourceNode.id, 'value', targetNode.id, 'saved_workflow_input::node-1::a')],
+ templates,
+ });
+
+ expect(edgeIdsToRemove).toEqual([]);
+ });
+
+ it('removes inbound edges when a previously connected field is no longer exposed', () => {
+ const sourceNode = buildNode(addTemplate);
+ const targetNode = buildNode(call_saved_workflow);
+ const edge = buildEdge(sourceNode.id, 'value', targetNode.id, 'saved_workflow_input::node-1::a');
+
+ const edgeIdsToRemove = getSavedWorkflowDynamicEdgeIdsToRemove({
+ nodeId: targetNode.id,
+ fields: [],
+ nodes: [sourceNode, targetNode],
+ edges: [edge],
+ templates,
+ });
+
+ expect(edgeIdsToRemove).toEqual([edge.id]);
+ });
+
+ it('removes inbound edges when an exposed field changes to an incompatible type', () => {
+ const sourceNode = buildNode(addTemplate);
+ const targetNode = buildNode(call_saved_workflow);
+ const edge = buildEdge(sourceNode.id, 'value', targetNode.id, 'saved_workflow_input::node-1::a');
+
+ const booleanTemplate: BooleanFieldInputTemplate = {
+ name: 'saved_workflow_input::node-1::a',
+ title: 'Enabled',
+ required: false,
+ description: 'Whether the workflow is enabled',
+ fieldKind: 'input',
+ input: 'any',
+ ui_hidden: false,
+ default: false,
+ type: {
+ name: 'BooleanField',
+ cardinality: 'SINGLE',
+ batch: false,
+ },
+ };
+
+ const edgeIdsToRemove = getSavedWorkflowDynamicEdgeIdsToRemove({
+ nodeId: targetNode.id,
+ fields: [
+ {
+ fieldName: 'saved_workflow_input::node-1::a',
+ fieldTemplate: booleanTemplate,
+ label: 'Enabled',
+ description: 'Whether the workflow is enabled',
+ initialValue: false,
+ settings: undefined,
+ },
+ ],
+ nodes: [sourceNode, targetNode],
+ edges: [edge],
+ templates,
+ });
+
+ expect(edgeIdsToRemove).toEqual([edge.id]);
+ });
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
index 00907da3c7b..1b55af05a58 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
@@ -1,8 +1,11 @@
import { addElement, getIsFormEmpty } from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX } from 'features/nodes/store/nodesSlice';
import type { Templates } from 'features/nodes/store/types';
+import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes';
import type { FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import { isStatefulFieldType } from 'features/nodes/types/field';
+import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
+import { isInvocationNode } from 'features/nodes/types/invocation';
import {
type BuilderForm,
buildNodeFieldElement,
@@ -244,3 +247,51 @@ export const getSavedWorkflowDynamicFields = (
return dynamicFields;
};
+
+export const getSavedWorkflowDynamicEdgeIdsToRemove = ({
+ nodeId,
+ fields,
+ nodes,
+ edges,
+ templates,
+}: {
+ nodeId: string;
+ fields: SavedWorkflowDynamicField[];
+ nodes: AnyNode[];
+ edges: AnyEdge[];
+ templates: Templates;
+}): string[] => {
+ const nextFieldTemplates = new Map(fields.map((field) => [field.fieldName, field.fieldTemplate]));
+
+ return edges.flatMap((edge) => {
+ if (edge.type !== 'default' || edge.target !== nodeId || !edge.targetHandle) {
+ return [];
+ }
+
+ if (!edge.targetHandle.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX)) {
+ return [];
+ }
+
+ const targetFieldTemplate = nextFieldTemplates.get(edge.targetHandle);
+ if (!targetFieldTemplate || targetFieldTemplate.input === 'direct' || !edge.sourceHandle) {
+ return [edge.id];
+ }
+
+ const sourceNode = nodes.find((node) => node.id === edge.source);
+ if (!sourceNode || !isInvocationNode(sourceNode)) {
+ return [edge.id];
+ }
+
+ const sourceTemplate = templates[sourceNode.data.type];
+ const sourceFieldTemplate = sourceTemplate?.outputs[edge.sourceHandle];
+ if (!sourceFieldTemplate) {
+ return [edge.id];
+ }
+
+ if (!validateConnectionTypes(sourceFieldTemplate.type, targetFieldTemplate.type)) {
+ return [edge.id];
+ }
+
+ return [];
+ });
+};
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
index 1b1edc086a5..41b1243ab4e 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
@@ -38,6 +38,7 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
initialValue: 23,
},
],
+ edgeIdsToRemove: [],
})
);
@@ -71,6 +72,7 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
initialValue: 23,
},
],
+ edgeIdsToRemove: [],
})
);
@@ -96,6 +98,7 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
initialValue: 23,
},
],
+ edgeIdsToRemove: [],
})
);
@@ -128,6 +131,7 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
initialValue: 23,
},
],
+ edgeIdsToRemove: [],
})
);
@@ -136,6 +140,7 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
callSavedWorkflowDynamicFieldsChanged({
nodeId: node.id,
fields: [],
+ edgeIdsToRemove: [],
})
);
@@ -147,4 +152,30 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
expect(resyncedNode.data.inputs[fieldName]).toBeUndefined();
expect(resyncedNode.data.dynamicInputTemplates[fieldName]).toBeUndefined();
});
+
+ it('removes specified inbound edges during dynamic field resync', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const sourceNode = buildNode(addTemplate);
+ const targetNode = buildNode(callSavedWorkflowTemplate);
+ state.nodes.push(sourceNode, targetNode);
+ state.edges.push({
+ id: 'edge-1',
+ type: 'default',
+ source: sourceNode.id,
+ sourceHandle: 'value',
+ target: targetNode.id,
+ targetHandle: 'saved_workflow_input::node-1::a',
+ });
+
+ const nextState = nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: targetNode.id,
+ fields: [],
+ edgeIdsToRemove: ['edge-1'],
+ })
+ );
+
+ expect(nextState.edges).toHaveLength(0);
+ });
});
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index a50dad2424b..044ecd7bb97 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -495,9 +495,10 @@ const slice = createSlice({
description: string;
initialValue: StatefulFieldValue;
}>;
+ edgeIdsToRemove: string[];
}>
) => {
- const { nodeId, fields } = action.payload;
+ const { nodeId, fields, edgeIdsToRemove } = action.payload;
const node = state.nodes.find((n) => n.id === nodeId);
if (!isInvocationNode(node) || node.data.type !== 'call_saved_workflow') {
return;
@@ -527,6 +528,11 @@ const slice = createSlice({
instance.value = initialValue;
node.data.inputs[fieldName] = instance;
}
+
+ if (edgeIdsToRemove.length > 0) {
+ const edgeIdsToRemoveSet = new Set(edgeIdsToRemove);
+ state.edges = state.edges.filter((edge) => !edgeIdsToRemoveSet.has(edge.id));
+ }
},
notesNodeValueChanged: (state, action: PayloadAction<{ nodeId: string; value: string }>) => {
const { nodeId, value } = action.payload;
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.test.ts
index 87271c627c9..dacc1434466 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.test.ts
@@ -40,6 +40,7 @@ describe('getInvocationNodeErrors', () => {
initialValue: 23,
},
],
+ edgeIdsToRemove: [],
})
);
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
index a7c6284c189..40122599f59 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
@@ -234,6 +234,7 @@ describe(validateConnection.name, () => {
initialValue: 23,
},
],
+ edgeIdsToRemove: [],
})
);
From c85e009ccccda72b1b681ca2e261fe0c1816778b Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Thu, 9 Apr 2026 08:58:05 -0500
Subject: [PATCH 027/100] Add call saved workflow design document
---
docs/contributing/call_saved_workflow.md | 310 +++++++++++++++++++++++
1 file changed, 310 insertions(+)
create mode 100644 docs/contributing/call_saved_workflow.md
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
new file mode 100644
index 00000000000..381fc307228
--- /dev/null
+++ b/docs/contributing/call_saved_workflow.md
@@ -0,0 +1,310 @@
+# Call Saved Workflow Architecture
+
+## Goal
+
+`CallSavedWorkflowInvocation` should become an engine-native workflow call boundary, not a frontend-only dynamic node and not a compile-time graph inliner.
+
+The long-term feature goal is:
+
+- A parent workflow can call a saved workflow selected by ID.
+- The call node redraws in the editor based on the selected workflow's exposed form fields.
+- Parent values and inbound connections bind to those exposed fields as call arguments.
+- Execution suspends at the call node, runs the selected workflow as a dependent workflow execution, captures explicit return values, and then resumes the parent workflow.
+- The architecture must work for Invoke frontend graphs and for externally submitted graphs that use the same node type.
+
+This document records the current state, the target architecture, and the execution contract needed to continue development later.
+
+## Implementation Priority
+
+Favor the architecturally correct design over the fastest implementation path.
+
+The work may still proceed incrementally, but each increment should satisfy all of the following:
+
+- testable in isolation
+- compatible with the long-term architecture described here
+- non-breaking to existing code and existing workflow execution behavior
+
+Speed is not the primary goal for this phase. The primary goal is to move toward the durable design without introducing throwaway execution semantics that would need to be unwound later.
+
+## Current State
+
+Implemented already:
+
+- A real invocation exists: `call_saved_workflow`.
+- The frontend provides a saved-workflow picker using a reusable `SavedWorkflowField` UI type.
+- The node redraws dynamically based on the selected saved workflow's exposed form fields.
+- Dynamic field values persist with the parent workflow.
+- Compatible inbound edges are preserved when switching between workflows with matching exposed field identities and compatible types.
+- Incompatible or no-longer-exposed inbound edges are removed in the editor.
+- Backend validation exists for `workflow_id` existence and access rights.
+
+Important limitation:
+
+- The backend invocation class only has a static `workflow_id` input.
+- Dynamic exposed fields currently exist only in frontend/editor state.
+- Fresh connections to dynamic handles fail at invoke time because backend graph validation checks destination fields against real Python model fields.
+
+Conclusion:
+
+- More frontend work alone will not make the node executable.
+- The next phase must be Python-side runtime architecture.
+
+## Architectural Direction
+
+Use the architecture that is more likely to be kept long-term:
+
+- `call_saved_workflow` is a call boundary.
+- The parent graph does not inline the full child workflow into itself at queue time.
+- Runtime execution pauses at the call node and creates a dependent child workflow execution.
+- The child workflow receives arguments from the parent.
+- The child workflow returns explicit outputs to the parent.
+- The parent resumes once the child returns successfully.
+
+This is preferred over full graph expansion because it:
+
+- avoids execution-graph blowup
+- preserves workflow boundaries
+- matches the conceptual model of workflow reuse
+- supports explicit return values
+- keeps externally submitted graphs viable as long as they use the same node type and contract
+
+## Non-Goals For The Next Phase
+
+These should not be the first implementation target:
+
+- full inline graph expansion of called workflows
+- unlimited nested workflow call support
+- automatic exposure of arbitrary internal child workflow state
+- implicit output inference from arbitrary child nodes
+
+## Execution Contract
+
+### 1. Callable Interface
+
+The callable interface of a saved workflow is defined by its saved workflow JSON.
+
+Primary source:
+
+- `workflow.form`
+
+Fallback source for older workflows:
+
+- `workflow.exposedFields`
+
+Only fields exposed by the child workflow form are callable inputs.
+Internal child inputs that exist in the workflow graph but are not exposed by the form are not part of the public call interface.
+
+### 2. Input Arguments
+
+`CallSavedWorkflowInvocation` exposes dynamic inputs in the editor based on the selected workflow's callable interface.
+
+Each dynamic input must have:
+
+- a stable external handle name
+- a type
+- a default value if defined by the child workflow
+- a user-facing label and description when available
+
+Current fast-path identity is based on child `nodeId + fieldName`. That is acceptable short-term in the editor, but a longer-term stable interface ID would be better if child workflows are frequently duplicated or refactored.
+
+### 3. Input Binding At Runtime
+
+At runtime, when the parent reaches `call_saved_workflow`:
+
+- the engine resolves `workflow_id`
+- the engine loads the selected child workflow record
+- the engine reconstructs the callable interface from the saved workflow JSON
+- the engine collects argument values from the parent node's dynamic inputs
+- the engine starts a dependent child workflow execution using those arguments
+
+Argument values may come from:
+
+- parent literal field values
+- resolved inbound connections into the call node's dynamic inputs
+
+### 4. Child Workflow Execution
+
+The child workflow runs as its own dependent execution context, not as an inlined copy of the parent graph.
+
+Desired semantics:
+
+- parent execution pauses at the call node
+- child execution runs with inherited context where appropriate
+- child workflow finishes or fails
+- parent resumes only if child execution succeeds
+
+This implies the queue/session/runtime layer needs an explicit parent-child execution relationship.
+
+### 5. Return Values
+
+Return values should be explicit.
+
+Recommended model:
+
+- introduce a workflow return node analogous in concept to Canvas Output
+- the child workflow declares what values it returns through that explicit node
+- the return node accepts a collection input
+- when the workflow is run independently, the return node has no caller-visible effect
+- when the workflow is run via `call_saved_workflow`, that collection becomes the return value of the call
+- `call_saved_workflow` should expose that collection as its return value in the first runtime version
+
+Only one workflow return node may exist per workflow.
+That rule should be enforced in both the frontend editor and in Python validation/runtime code.
+
+Do not infer child outputs from arbitrary terminal nodes.
+That is too ambiguous and too brittle.
+
+### 6. Error Propagation
+
+If child execution fails:
+
+- the call node fails
+- the parent workflow fails unless a later design adds explicit error-handling semantics
+
+For the first implementation, failure propagation should be simple and strict.
+
+### 7. Access Control
+
+Runtime must enforce the same access rules used elsewhere for saved workflows.
+
+The caller may execute a child workflow only if it is allowed to access that saved workflow at runtime.
+
+This matters even if the parent workflow was authored in a context where the child was once visible.
+
+### 8. Recursion And Nesting
+
+Initial implementation should forbid:
+
+- direct self-call
+- obvious recursion cycles
+- nested `call_saved_workflow` inside a called child workflow
+
+This keeps the first runtime implementation bounded.
+Nested calls can be revisited later.
+
+## Where The Runtime Work Belongs
+
+The goal is to support externally submitted graphs, not only frontend-authored graphs.
+Therefore the authoritative execution logic must live in Python.
+
+Recommended high-level design:
+
+- a backend `GraphExpander` or broader graph-preparation service may still exist as an abstraction point
+- but for this feature, the preferred long-term runtime model is not full graph expansion
+- instead, the runtime needs a call-execution mechanism in the Python execution stack
+
+Relevant existing path:
+
+- frontend builds and submits a graph and workflow payload
+- backend receives the batch via session queue APIs
+- session queue stores session state
+- runtime executes through `GraphExecutionState`
+
+The next phase should identify the best Python insertion point for:
+
+- detecting when the next executable node is `call_saved_workflow`
+- suspending parent execution
+- launching a dependent child execution
+- collecting child return values
+- resuming the parent graph
+
+At a minimum, expect changes in Python runtime/session code rather than only in queue submission code.
+
+## Suggested Runtime Components
+
+### CallSavedWorkflowRuntime
+
+A dedicated runtime helper for this node type should be introduced.
+Responsibilities:
+
+- load and validate the selected child workflow record
+- validate runtime access rights
+- extract callable inputs from the child workflow definition
+- build child execution arguments from the parent node state
+- launch dependent execution
+- collect declared returns
+- map returned values back to the parent node outputs
+
+### Workflow Return Node
+
+A dedicated child-workflow return node should be introduced.
+Responsibilities:
+
+- define the return interface of the called workflow
+- accept a collection input representing the workflow result
+- provide that collection back to the parent call site when invoked through `call_saved_workflow`
+- remain inert from a caller perspective when the workflow is run independently
+- guarantee that only one such node exists per workflow
+
+This should likely become the canonical reusable return mechanism for any future subworkflow call behavior.
+
+### Execution Relationship Tracking
+
+Session/runtime state will likely need to record:
+
+- parent execution waiting on child execution
+- child execution belonging to a parent node call site
+- result propagation back to the parent
+- strict failure propagation rules
+
+## Frontend Responsibilities In The Long-Term Design
+
+The frontend remains responsible for editor-time behavior:
+
+- choosing the saved workflow
+- redrawing dynamic inputs based on the child workflow callable interface
+- persisting those dynamic fields and their values
+- preserving compatible inbound edges when workflow selection changes
+- removing no-longer-valid inbound edges when the callable interface changes
+- eventually redrawing outputs if and when explicit workflow returns are added
+
+The frontend should not be the authoritative implementation of execution semantics.
+
+## Questions To Resolve Before Coding The Runtime
+
+1. Where exactly does parent execution pause and child execution resume in the current runtime stack?
+2. What is the narrowest first implementation of parent-child session state?
+3. The first runtime version should use the explicit workflow return node with a single collection-valued return, rather than inputs-only or ad hoc fixed outputs.
+4. Should child execution inherit all parent execution context, or only selected parts?
+5. What cancellation semantics apply if the parent session is cancelled while a child workflow is running?
+6. What metadata should be stored on queue items or sessions to represent call relationships?
+7. Do dynamic input identities need a more stable external interface ID before runtime work begins?
+
+## Recommended Next Steps
+
+1. Design the explicit workflow return mechanism.
+2. Trace the Python runtime path needed to suspend and resume execution around a call node.
+3. Define a minimal parent-child session relationship model.
+4. Prototype runtime input passing for `call_saved_workflow` without nested calls.
+5. Add the workflow return node with frontend and Python enforcement that only one return node may exist per workflow.
+6. Add strict recursion guards.
+7. Add end-to-end tests for successful child call execution, missing child workflow, unauthorized child workflow, duplicate return nodes, and child failure propagation.
+
+## Minimum Test Matrix For The Next Phase
+
+Positive tests:
+
+- parent workflow calls child workflow successfully with literal arguments
+- parent workflow calls child workflow successfully with connected arguments
+- child workflow returns its explicit collection value to the parent
+- runtime enforces child defaults when parent does not override them
+
+Negative tests:
+
+- missing selected workflow fails cleanly
+- unauthorized selected workflow fails cleanly
+- child workflow missing return definition fails cleanly if returns are required
+- duplicate workflow return nodes are rejected
+- direct self-call is rejected
+- nested workflow calls are rejected in the first implementation
+- child workflow failure propagates to the parent
+- cancellation while child is running produces a deterministic failure state
+
+## Summary
+
+The project is past the frontend proof-of-concept stage.
+
+What remains is a real engine-level workflow call mechanism.
+The architecture most likely to be kept is a runtime call boundary with dependent child execution and explicit returns, not compile-time graph inlining.
+
+That should be the basis for the next phase of work.
From 981b2b8dc498c74c76b3fbb21a7faac531b73752 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Thu, 9 Apr 2026 09:13:21 -0500
Subject: [PATCH 028/100] Clarify workflow return runtime design
---
docs/contributing/call_saved_workflow.md | 23 ++++++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 381fc307228..03b743fe5cf 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -235,6 +235,7 @@ Responsibilities:
- provide that collection back to the parent call site when invoked through `call_saved_workflow`
- remain inert from a caller perspective when the workflow is run independently
- guarantee that only one such node exists per workflow
+- behave as a normal node in the editor, with singularity enforced by both frontend and Python validation/runtime code
This should likely become the canonical reusable return mechanism for any future subworkflow call behavior.
@@ -247,6 +248,26 @@ Session/runtime state will likely need to record:
- result propagation back to the parent
- strict failure propagation rules
+### Workflow Return Value Flow
+
+The workflow return value should not be persisted back into the saved workflow record and should not be derived from frontend state.
+
+The intended runtime flow is:
+
+1. The child workflow computes the `workflow_return` node's collection input like any other node input.
+2. When the child reaches `workflow_return`, runtime captures the resolved collection value as the child workflow result.
+3. That result is stored in child execution state, or equivalent parent-child call-frame state, until the child finishes.
+4. When the child finishes successfully, the captured collection is passed back to the suspended parent call site.
+5. `call_saved_workflow` completes using that collection as its output value.
+6. The parent workflow resumes execution.
+
+Consequences of this model:
+
+- `workflow_return` is a normal invocation node in the child workflow
+- only one workflow return result may exist, because only one return node is allowed per workflow
+- the child result should live in runtime/session state, not in workflow persistence
+- return propagation should be explicit and deterministic
+
## Frontend Responsibilities In The Long-Term Design
The frontend remains responsible for editor-time behavior:
@@ -267,7 +288,7 @@ The frontend should not be the authoritative implementation of execution semanti
3. The first runtime version should use the explicit workflow return node with a single collection-valued return, rather than inputs-only or ad hoc fixed outputs.
4. Should child execution inherit all parent execution context, or only selected parts?
5. What cancellation semantics apply if the parent session is cancelled while a child workflow is running?
-6. What metadata should be stored on queue items or sessions to represent call relationships?
+6. What metadata should be stored on queue items or sessions to represent call relationships and the captured child return value?
7. Do dynamic input identities need a more stable external interface ID before runtime work begins?
## Recommended Next Steps
From 3d991ea54b80fe408f77d31207c830765d5236f4 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Thu, 9 Apr 2026 09:37:49 -0500
Subject: [PATCH 029/100] Add workflow return node contract
---
docs/contributing/call_saved_workflow.md | 6 +-
invokeai/app/invocations/workflow_return.py | 45 ++++++
.../workflow_records_common.py | 17 +++
.../features/nodes/store/util/testUtils.ts | 128 ++++++++++++++++++
.../web/src/features/nodes/types/workflow.ts | 52 ++++---
.../nodes/util/schema/parseSchema.test.ts | 16 ++-
.../util/workflow/validateWorkflow.test.ts | 43 +++++-
.../frontend/web/src/services/api/schema.ts | 67 ++++++++-
.../invocations/test_call_saved_workflows.py | 57 ++++++++
9 files changed, 400 insertions(+), 31 deletions(-)
create mode 100644 invokeai/app/invocations/workflow_return.py
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 03b743fe5cf..14607104386 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -143,7 +143,7 @@ Recommended model:
- introduce a workflow return node analogous in concept to Canvas Output
- the child workflow declares what values it returns through that explicit node
-- the return node accepts a collection input
+- the return node accepts a `list[Any]` collection input
- when the workflow is run independently, the return node has no caller-visible effect
- when the workflow is run via `call_saved_workflow`, that collection becomes the return value of the call
- `call_saved_workflow` should expose that collection as its return value in the first runtime version
@@ -231,7 +231,7 @@ A dedicated child-workflow return node should be introduced.
Responsibilities:
- define the return interface of the called workflow
-- accept a collection input representing the workflow result
+- accept a `list[Any]` collection input representing the workflow result
- provide that collection back to the parent call site when invoked through `call_saved_workflow`
- remain inert from a caller perspective when the workflow is run independently
- guarantee that only one such node exists per workflow
@@ -307,7 +307,7 @@ Positive tests:
- parent workflow calls child workflow successfully with literal arguments
- parent workflow calls child workflow successfully with connected arguments
-- child workflow returns its explicit collection value to the parent
+- child workflow returns its explicit `list[Any]` collection value to the parent
- runtime enforces child defaults when parent does not override them
Negative tests:
diff --git a/invokeai/app/invocations/workflow_return.py b/invokeai/app/invocations/workflow_return.py
new file mode 100644
index 00000000000..66f383e598b
--- /dev/null
+++ b/invokeai/app/invocations/workflow_return.py
@@ -0,0 +1,45 @@
+from typing import Any
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import InputField, OutputField, UIType
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+@invocation_output("workflow_return_output")
+class WorkflowReturnOutput(BaseInvocationOutput):
+ """The explicit collection returned from a callable workflow."""
+
+ collection: list[Any] = OutputField(
+ description="The workflow return collection",
+ title="Collection",
+ ui_type=UIType._Collection,
+ )
+
+
+@invocation(
+ "workflow_return",
+ title="Workflow Return",
+ tags=["workflow", "return", "output"],
+ category="workflow",
+ version="1.0.0",
+ classification=Classification.Beta,
+ use_cache=False,
+)
+class WorkflowReturnInvocation(BaseInvocation):
+ """Defines the explicit collection result returned by a callable workflow."""
+
+ collection: list[Any] = InputField(
+ default=[],
+ description="The collection returned to a calling workflow.",
+ title="Collection",
+ ui_type=UIType._Collection,
+ )
+
+ def invoke(self, context: InvocationContext) -> WorkflowReturnOutput:
+ return WorkflowReturnOutput(collection=self.collection)
diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py
index 9c505530c90..7bce5346b1e 100644
--- a/invokeai/app/services/workflow_records/workflow_records_common.py
+++ b/invokeai/app/services/workflow_records/workflow_records_common.py
@@ -73,6 +73,23 @@ class WorkflowWithoutID(BaseModel):
model_config = ConfigDict(extra="ignore")
+ @field_validator("nodes")
+ @classmethod
+ def validate_workflow_return_node_uniqueness(cls, nodes: list[dict[str, JsonValue]]):
+ workflow_return_count = 0
+
+ for node in nodes:
+ if not isinstance(node, dict) or node.get("type") != "invocation":
+ continue
+ data = node.get("data")
+ if isinstance(data, dict) and data.get("type") == "workflow_return":
+ workflow_return_count += 1
+
+ if workflow_return_count > 1:
+ raise ValueError("A workflow may not contain more than one workflow_return node.")
+
+ return nodes
+
WorkflowWithoutIDValidator = TypeAdapter(WorkflowWithoutID)
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
index 67581ec187d..30e5369f562 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
@@ -121,6 +121,51 @@ export const call_saved_workflow: InvocationTemplate = {
classification: 'beta',
};
+export const workflow_return: InvocationTemplate = {
+ title: 'Workflow Return',
+ type: 'workflow_return',
+ version: '1.0.0',
+ tags: ['workflow', 'return', 'output'],
+ description: 'Defines the explicit collection result returned by a callable workflow.',
+ outputType: 'workflow_return_output',
+ inputs: {
+ collection: {
+ name: 'collection',
+ title: 'Collection',
+ required: false,
+ description: 'The collection returned to a calling workflow.',
+ fieldKind: 'input',
+ input: 'connection',
+ ui_hidden: false,
+ ui_type: 'CollectionField',
+ type: {
+ name: 'CollectionField',
+ cardinality: 'COLLECTION',
+ batch: false,
+ },
+ default: undefined,
+ },
+ },
+ outputs: {
+ collection: {
+ fieldKind: 'output',
+ name: 'collection',
+ title: 'Collection',
+ description: 'The workflow return collection',
+ type: {
+ name: 'CollectionField',
+ cardinality: 'COLLECTION',
+ batch: false,
+ },
+ ui_hidden: false,
+ ui_type: 'CollectionField',
+ },
+ },
+ useCache: false,
+ nodePack: 'invokeai',
+ classification: 'beta',
+};
+
export const sub: InvocationTemplate = {
title: 'Subtract Integers',
type: 'sub',
@@ -580,6 +625,7 @@ const iterate: InvocationTemplate = {
export const templates: Templates = {
add,
call_saved_workflow,
+ workflow_return,
sub,
collect,
iterate,
@@ -655,6 +701,88 @@ export const schema = {
},
class: 'invocation',
},
+ WorkflowReturnInvocation: {
+ properties: {
+ id: {
+ type: 'string',
+ title: 'Id',
+ description: 'The id of this instance of an invocation. Must be unique among all instances of invocations.',
+ field_kind: 'node_attribute',
+ },
+ is_intermediate: {
+ type: 'boolean',
+ title: 'Is Intermediate',
+ description: 'Whether or not this is an intermediate invocation.',
+ default: false,
+ field_kind: 'node_attribute',
+ ui_type: 'IsIntermediate',
+ },
+ use_cache: {
+ type: 'boolean',
+ title: 'Use Cache',
+ description: 'Whether or not to use the cache',
+ default: false,
+ field_kind: 'node_attribute',
+ },
+ collection: {
+ type: 'array',
+ items: {},
+ title: 'Collection',
+ description: 'The collection returned to a calling workflow.',
+ field_kind: 'input',
+ input: 'connection',
+ orig_required: false,
+ ui_hidden: false,
+ ui_type: 'CollectionField',
+ },
+ type: {
+ type: 'string',
+ enum: ['workflow_return'],
+ const: 'workflow_return',
+ title: 'type',
+ default: 'workflow_return',
+ field_kind: 'node_attribute',
+ },
+ },
+ type: 'object',
+ required: ['type', 'id'],
+ title: 'Workflow Return',
+ description: 'Defines the explicit collection result returned by a callable workflow.',
+ category: 'workflow',
+ classification: 'beta',
+ node_pack: 'invokeai',
+ tags: ['workflow', 'return', 'output'],
+ version: '1.0.0',
+ output: {
+ $ref: '#/components/schemas/WorkflowReturnOutput',
+ },
+ class: 'invocation',
+ },
+ WorkflowReturnOutput: {
+ properties: {
+ type: {
+ type: 'string',
+ enum: ['workflow_return_output'],
+ const: 'workflow_return_output',
+ title: 'type',
+ default: 'workflow_return_output',
+ field_kind: 'node_attribute',
+ },
+ collection: {
+ type: 'array',
+ items: {},
+ title: 'Collection',
+ description: 'The workflow return collection',
+ field_kind: 'output',
+ ui_hidden: false,
+ ui_type: 'CollectionField',
+ },
+ },
+ type: 'object',
+ required: ['type', 'collection'],
+ title: 'Workflow Return Output',
+ class: 'output',
+ },
AddInvocation: {
properties: {
id: {
diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts
index 66e69ec5859..a2630cca81d 100644
--- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts
@@ -363,24 +363,38 @@ const zValidatedBuilderForm = zBuilderForm
//# endregion
// #region Workflow
-export const zWorkflowV3 = z.object({
- id: z.string().min(1).optional(),
- name: z.string(),
- author: z.string(),
- description: z.string(),
- version: z.string(),
- contact: z.string(),
- tags: z.string(),
- notes: z.string(),
- nodes: z.array(zWorkflowNode),
- edges: z.array(zWorkflowEdge),
- exposedFields: z.array(zFieldIdentifier),
- meta: z.object({
- category: zWorkflowCategory.default('user'),
- version: z.literal('3.0.0'),
- }),
- // Use the validated form schema!
- form: zValidatedBuilderForm,
-});
+export const zWorkflowV3 = z
+ .object({
+ id: z.string().min(1).optional(),
+ name: z.string(),
+ author: z.string(),
+ description: z.string(),
+ version: z.string(),
+ contact: z.string(),
+ tags: z.string(),
+ notes: z.string(),
+ nodes: z.array(zWorkflowNode),
+ edges: z.array(zWorkflowEdge),
+ exposedFields: z.array(zFieldIdentifier),
+ meta: z.object({
+ category: zWorkflowCategory.default('user'),
+ version: z.literal('3.0.0'),
+ }),
+ // Use the validated form schema!
+ form: zValidatedBuilderForm,
+ })
+ .superRefine((workflow, ctx) => {
+ const workflowReturnCount = workflow.nodes.filter(
+ (node) => node.type === 'invocation' && node.data.type === 'workflow_return'
+ ).length;
+
+ if (workflowReturnCount > 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'A workflow may not contain more than one workflow_return node.',
+ path: ['nodes'],
+ });
+ }
+ });
export type WorkflowV3 = z.infer;
// #endregion
diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
index a126b7415ea..1317f973ddf 100644
--- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts
@@ -1,5 +1,5 @@
import { omit, pick } from 'es-toolkit/compat';
-import { call_saved_workflow, schema, templates } from 'features/nodes/store/util/testUtils';
+import { call_saved_workflow, schema, templates, workflow_return } from 'features/nodes/store/util/testUtils';
import { parseSchema } from 'features/nodes/util/schema/parseSchema';
import { describe, expect, it } from 'vitest';
@@ -32,4 +32,18 @@ describe('parseSchema', () => {
expect(workflowIdInput.type.name).toBe('SavedWorkflowField');
expect(workflowIdInput.ui_type).toBe('SavedWorkflowField');
});
+ it('should parse the workflow_return node template', () => {
+ const parsed = parseSchema(schema);
+ expect(stripUndefinedDeep(parsed.workflow_return)).toEqual(stripUndefinedDeep(workflow_return));
+ const template = parsed.workflow_return;
+ if (!template) {
+ throw new Error('Expected workflow_return template');
+ }
+ const collectionInput = template.inputs.collection;
+ if (!collectionInput) {
+ throw new Error('Expected collection input');
+ }
+ expect(collectionInput.type.name).toBe('CollectionField');
+ expect(collectionInput.ui_type).toBe('CollectionField');
+ });
});
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
index 5e8aed52769..bca9466f5a4 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
@@ -1,7 +1,8 @@
import { get } from 'es-toolkit/compat';
-import { img_resize, main_model_loader } from 'features/nodes/store/util/testUtils';
+import { img_resize, main_model_loader, workflow_return } from 'features/nodes/store/util/testUtils';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { getDefaultForm } from 'features/nodes/types/workflow';
+import { buildInvocationNode } from 'features/nodes/util/node/buildInvocationNode';
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
import { describe, expect, it } from 'vitest';
@@ -129,4 +130,44 @@ describe('validateWorkflow', () => {
expect(validationResult.warnings.length).toBe(1);
expect(get(validationResult, 'workflow.nodes[0].data.inputs.model.value')).toBeUndefined();
});
+ it('should reject workflows with duplicate workflow_return nodes at build time', async () => {
+ Object.assign(globalThis, {
+ window: {
+ location: {
+ origin: 'http://localhost',
+ },
+ },
+ });
+
+ const { buildWorkflowWithValidation } = await import('features/nodes/util/workflow/buildWorkflow');
+ const returnNode1 = buildInvocationNode({ x: 0, y: 0 }, workflow_return);
+ const returnNode2 = buildInvocationNode({ x: 100, y: 0 }, workflow_return);
+
+ const built = buildWorkflowWithValidation({
+ _version: 1,
+ formFieldInitialValues: {},
+ ...getWorkflow(),
+ nodes: [returnNode1, returnNode2],
+ edges: [],
+ });
+
+ expect(built).toBeNull();
+ });
+ it('should warn when loading a workflow with duplicate workflow_return nodes', async () => {
+ const returnNode1 = buildInvocationNode({ x: 0, y: 0 }, workflow_return);
+ const returnNode2 = buildInvocationNode({ x: 100, y: 0 }, workflow_return);
+
+ await expect(
+ validateWorkflow({
+ workflow: {
+ ...getWorkflow(),
+ nodes: [returnNode1, returnNode2],
+ },
+ templates: { img_resize, main_model_loader, workflow_return },
+ checkImageAccess: resolveTrue,
+ checkBoardAccess: resolveTrue,
+ checkModelAccess: resolveTrue,
+ })
+ ).rejects.toThrow(/workflow_return/i);
+ });
});
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 09927852973..15d4fc590ff 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -10878,7 +10878,7 @@ export type components = {
* @description The nodes in this graph
*/
nodes?: {
- [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
};
/**
* Edges
@@ -10915,7 +10915,7 @@ export type components = {
* @description The results of node executions
*/
results: {
- [key: string]: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
+ [key: string]: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
};
/**
* Errors
@@ -14162,7 +14162,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -14172,7 +14172,7 @@ export type components = {
* Result
* @description The result of the invocation
*/
- result: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
+ result: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
};
/**
* InvocationErrorEvent
@@ -14226,7 +14226,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -14473,6 +14473,7 @@ export type components = {
tomask: components["schemas"]["ImageOutput"];
unsharp_mask: components["schemas"]["ImageOutput"];
vae_loader: components["schemas"]["VAEOutput"];
+ workflow_return: components["schemas"]["WorkflowReturnOutput"];
z_image_control: components["schemas"]["ZImageControlOutput"];
z_image_denoise: components["schemas"]["LatentsOutput"];
z_image_denoise_meta: components["schemas"]["LatentsMetaOutput"];
@@ -14536,7 +14537,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -14611,7 +14612,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -28169,6 +28170,58 @@ export type components = {
*/
thumbnail_url?: string | null;
};
+ /**
+ * Workflow Return
+ * @description Defines the explicit collection result returned by a callable workflow.
+ */
+ WorkflowReturnInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default false
+ */
+ use_cache?: boolean;
+ /**
+ * Collection
+ * @description The collection returned to a calling workflow.
+ * @default []
+ */
+ collection?: unknown[];
+ /**
+ * type
+ * @default workflow_return
+ * @constant
+ */
+ type: "workflow_return";
+ };
+ /**
+ * WorkflowReturnOutput
+ * @description The explicit collection returned from a callable workflow.
+ */
+ WorkflowReturnOutput: {
+ /**
+ * Collection
+ * @description The workflow return collection
+ */
+ collection: unknown[];
+ /**
+ * type
+ * @default workflow_return_output
+ * @constant
+ */
+ type: "workflow_return_output";
+ };
/**
* WorkflowUpdatedEvent
* @description Event model for workflow_updated
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index b2b6c224863..a8739e3224f 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -10,6 +10,7 @@
WorkflowMeta,
WorkflowNotFoundError,
WorkflowRecordDTO,
+ WorkflowWithoutIDValidator,
)
@@ -240,3 +241,59 @@ def test_call_saved_workflow_invocation_schema_declares_saved_workflow_ui_type()
assert workflow_id["default"] == ""
assert workflow_id["input"] == "any"
assert workflow_id["ui_type"] == "SavedWorkflowField"
+
+
+def test_workflow_return_invocation_contract():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnOutput
+
+ invocation = WorkflowReturnInvocation(id="return-node", collection=["a", 1, {"x": True}])
+
+ assert invocation.get_type() == "workflow_return"
+
+ output = invocation.invoke(build_context())
+
+ assert isinstance(output, WorkflowReturnOutput)
+ assert output.collection == ["a", 1, {"x": True}]
+
+
+def test_workflow_return_invocation_schema_declares_collection_ui_type():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation
+
+ schema = WorkflowReturnInvocation.model_json_schema()
+ collection = schema["properties"]["collection"]
+
+ assert collection["input"] == "any"
+ assert collection["ui_type"] == "CollectionField"
+
+
+def test_workflow_without_id_validator_rejects_duplicate_workflow_return_nodes():
+ with pytest.raises(ValueError, match="workflow_return"):
+ WorkflowWithoutIDValidator.validate_python(
+ {
+ "name": "Workflow With Duplicate Returns",
+ "author": "Tester",
+ "description": "",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "",
+ "notes": "",
+ "exposedFields": [],
+ "meta": {"version": "1.0.0", "category": "user"},
+ "nodes": [
+ {
+ "id": "return-1",
+ "type": "invocation",
+ "data": {"id": "return-1", "type": "workflow_return"},
+ "position": {"x": 0, "y": 0},
+ },
+ {
+ "id": "return-2",
+ "type": "invocation",
+ "data": {"id": "return-2", "type": "workflow_return"},
+ "position": {"x": 100, "y": 0},
+ },
+ ],
+ "edges": [],
+ "form": None,
+ }
+ )
From 4e6f848a228fd59ca2c7f47cbf836b1819f41aaa Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Thu, 9 Apr 2026 09:41:15 -0500
Subject: [PATCH 030/100] Allow bounded recursive workflow calls
---
docs/contributing/call_saved_workflow.md | 22 ++++++++++++----------
1 file changed, 12 insertions(+), 10 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 14607104386..c48ed252baf 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -173,14 +173,16 @@ This matters even if the parent workflow was authored in a context where the chi
### 8. Recursion And Nesting
-Initial implementation should forbid:
+Nested and recursive `call_saved_workflow` execution should be allowed, but bounded.
-- direct self-call
-- obvious recursion cycles
-- nested `call_saved_workflow` inside a called child workflow
+Initial implementation should enforce:
-This keeps the first runtime implementation bounded.
-Nested calls can be revisited later.
+- nested workflow calls are allowed
+- recursive workflow calls are allowed
+- maximum workflow call depth is capped at 4 call frames
+- the depth cap is enforced at runtime, based on the active call stack, not by static validation alone
+
+This allows legitimate recursive or conditionally terminating workflow structures while still preventing unbounded call growth.
## Where The Runtime Work Belongs
@@ -298,8 +300,8 @@ The frontend should not be the authoritative implementation of execution semanti
3. Define a minimal parent-child session relationship model.
4. Prototype runtime input passing for `call_saved_workflow` without nested calls.
5. Add the workflow return node with frontend and Python enforcement that only one return node may exist per workflow.
-6. Add strict recursion guards.
-7. Add end-to-end tests for successful child call execution, missing child workflow, unauthorized child workflow, duplicate return nodes, and child failure propagation.
+6. Add runtime call-stack tracking and maximum-depth enforcement.
+7. Add end-to-end tests for successful child call execution, missing child workflow, unauthorized child workflow, duplicate return nodes, maximum-depth failures, and child failure propagation.
## Minimum Test Matrix For The Next Phase
@@ -316,8 +318,8 @@ Negative tests:
- unauthorized selected workflow fails cleanly
- child workflow missing return definition fails cleanly if returns are required
- duplicate workflow return nodes are rejected
-- direct self-call is rejected
-- nested workflow calls are rejected in the first implementation
+- recursive calls that exceed the maximum depth are rejected
+- nested calls that exceed the maximum depth are rejected
- child workflow failure propagates to the parent
- cancellation while child is running produces a deterministic failure state
From b86e289aed4d7ecc198922159f596d2d8ecff801 Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Thu, 9 Apr 2026 21:16:31 -0400
Subject: [PATCH 031/100] fix (backend): improve user isolation for session
queue and recall parameters
- Sanitize session queue information of all cross-user fields except for the timestamps and status.
- Recall parameters are now user-scoped.
- Queue status endpoints now report user-scoped activity rather than global activity
- Tests added:
TestSessionQueueSanitization (4 tests):
1. test_owner_sees_all_fields - Owner sees complete queue item data
2. test_admin_sees_all_fields - Admin sees complete queue item data
3. test_non_owner_sees_only_status_timestamps_errors -
Non-owner sees only item_id, queue_id, status, and timestamps; everything else is redacted
4. test_sanitization_does_not_mutate_original - Sanitization doesn't modify the original object
TestRecallParametersIsolation (2 tests):
5. test_user1_write_does_not_leak_to_user2 - User1's recall params are not visible in user2's client state
6. test_two_users_independent_state - Both users can write recall params independently without overwriting each other
fix(backend): queue status endpoints report user-scoped stats rather than global stats
---
invokeai/app/api/routers/recall_parameters.py | 4 +-
invokeai/app/api/routers/session_queue.py | 30 +++-
.../session_queue/session_queue_base.py | 3 +-
.../session_queue/session_queue_sqlite.py | 10 +-
invokeai/frontend/web/public/locales/en.json | 1 +
.../InvokeButtonTooltip.tsx | 39 +++-
.../routers/test_multiuser_authorization.py | 170 ++++++++++++++++++
7 files changed, 235 insertions(+), 22 deletions(-)
diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py
index d0aef30ff8a..75c77ea0e97 100644
--- a/invokeai/app/api/routers/recall_parameters.py
+++ b/invokeai/app/api/routers/recall_parameters.py
@@ -337,14 +337,14 @@ async def update_recall_parameters(
if not provided_params:
return {"status": "no_parameters_provided", "updated_count": 0}
- # Store each parameter in client state using a consistent key format
+ # Store each parameter in client state scoped to the current user
updated_count = 0
for param_key, param_value in provided_params.items():
# Convert parameter values to JSON strings for storage
value_str = json.dumps(param_value)
try:
ApiDependencies.invoker.services.client_state_persistence.set_by_key(
- queue_id, f"recall_{param_key}", value_str
+ current_user.user_id, f"recall_{param_key}", value_str
)
updated_count += 1
except Exception as e:
diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py
index fdb2e1dd569..d5501037c15 100644
--- a/invokeai/app/api/routers/session_queue.py
+++ b/invokeai/app/api/routers/session_queue.py
@@ -44,7 +44,8 @@ def sanitize_queue_item_for_user(
"""Sanitize queue item for non-admin users viewing other users' items.
For non-admin users viewing queue items belonging to other users,
- the field_values, session graph, and workflow should be hidden/cleared to protect privacy.
+ only timestamps, status, and error information are exposed. All other
+ fields (user identity, generation parameters, graphs, workflows) are stripped.
Args:
queue_item: The queue item to sanitize
@@ -58,15 +59,25 @@ def sanitize_queue_item_for_user(
if is_admin or queue_item.user_id == current_user_id:
return queue_item
- # For non-admins viewing other users' items, clear sensitive fields
- # Create a shallow copy to avoid mutating the original
+ # For non-admins viewing other users' items, strip everything except
+ # item_id, queue_id, status, and timestamps
sanitized_item = queue_item.model_copy(deep=False)
+ sanitized_item.user_id = "redacted"
+ sanitized_item.user_display_name = None
+ sanitized_item.user_email = None
+ sanitized_item.batch_id = "redacted"
+ sanitized_item.session_id = "redacted"
+ sanitized_item.origin = None
+ sanitized_item.destination = None
+ sanitized_item.priority = 0
sanitized_item.field_values = None
+ sanitized_item.retried_from_item_id = None
sanitized_item.workflow = None
- # Clear the session graph by replacing it with an empty graph execution state
- # This prevents information leakage through the generation graph
+ sanitized_item.error_type = None
+ sanitized_item.error_message = None
+ sanitized_item.error_traceback = None
sanitized_item.session = GraphExecutionState(
- id=queue_item.session.id,
+ id="redacted",
graph=Graph(),
)
return sanitized_item
@@ -130,9 +141,12 @@ async def get_queue_item_ids(
queue_id: str = Path(description="The queue id to perform this operation on"),
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
) -> ItemIdsResult:
- """Gets all queue item ids that match the given parameters"""
+ """Gets all queue item ids that match the given parameters. Non-admin users only see their own items."""
try:
- return ApiDependencies.invoker.services.session_queue.get_queue_item_ids(queue_id=queue_id, order_dir=order_dir)
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.get_queue_item_ids(
+ queue_id=queue_id, order_dir=order_dir, user_id=user_id
+ )
except Exception as e:
raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}")
diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py
index 3c037dc77ab..375bc0e2940 100644
--- a/invokeai/app/services/session_queue/session_queue_base.py
+++ b/invokeai/app/services/session_queue/session_queue_base.py
@@ -172,8 +172,9 @@ def get_queue_item_ids(
self,
queue_id: str,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ user_id: Optional[str] = None,
) -> ItemIdsResult:
- """Gets all queue item ids that match the given parameters"""
+ """Gets all queue item ids that match the given parameters. If user_id is provided, only returns items for that user."""
pass
@abstractmethod
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index 4f46136fd79..81f1a4601c9 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -765,15 +765,21 @@ def get_queue_item_ids(
self,
queue_id: str,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ user_id: Optional[str] = None,
) -> ItemIdsResult:
with self._db.transaction() as cursor_:
query = f"""--sql
SELECT item_id
FROM session_queue
WHERE queue_id = ?
- ORDER BY created_at {order_dir.value}
"""
- query_params = [queue_id]
+ query_params: list[str] = [queue_id]
+
+ if user_id is not None:
+ query += " AND user_id = ?"
+ query_params.append(user_id)
+
+ query += f" ORDER BY created_at {order_dir.value}"
cursor_.execute(query, query_params)
result = cast(list[sqlite3.Row], cursor_.fetchall())
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 3e9b609223a..dc0c2346fd4 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1499,6 +1499,7 @@
"info": "Info",
"invoke": {
"addingImagesTo": "Adding images to",
+ "boardNotWritable": "You do not have write access to board \"{{boardName}}\". Select a board you own or switch to Uncategorized.",
"modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your account settings to upgrade.",
"invoke": "Invoke",
"missingFieldTemplate": "Missing field template",
diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx
index 9f1d004ba87..61553910e25 100644
--- a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx
@@ -17,6 +17,8 @@ import type { PropsWithChildren } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { enqueueMutationFixedCacheKeyOptions, useEnqueueBatchMutation } from 'services/api/endpoints/queue';
+import { useAutoAddBoard } from 'services/api/hooks/useAutoAddBoard';
+import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
import { useBoardName } from 'services/api/hooks/useBoardName';
type Props = TooltipProps & {
@@ -53,19 +55,25 @@ TooltipContent.displayName = 'TooltipContent';
const CanvasTabTooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => {
const isReady = useStore($isReadyToEnqueue);
const reasons = useStore($reasonsWhyCannotEnqueue);
+ const autoAddBoard = useAutoAddBoard();
+ const { canWriteImages } = useBoardAccess(autoAddBoard);
return (
-
+
- {reasons.length > 0 && (
+ {(reasons.length > 0 || !canWriteImages) && (
<>
-
+
+ >
+ )}
+ {canWriteImages && (
+ <>
+
+
>
)}
-
-
);
});
@@ -74,15 +82,17 @@ CanvasTabTooltipContent.displayName = 'CanvasTabTooltipContent';
const UpscaleTabTooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => {
const isReady = useStore($isReadyToEnqueue);
const reasons = useStore($reasonsWhyCannotEnqueue);
+ const autoAddBoard = useAutoAddBoard();
+ const { canWriteImages } = useBoardAccess(autoAddBoard);
return (
-
+
- {reasons.length > 0 && (
+ {(reasons.length > 0 || !canWriteImages) && (
<>
-
+
>
)}
@@ -195,12 +205,23 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
});
IsReadyText.displayName = 'IsReadyText';
-const ReasonsList = memo(({ reasons }: { reasons: Reason[] }) => {
+const ReasonsList = memo(({ reasons, canWriteImages = true }: { reasons: Reason[]; canWriteImages?: boolean }) => {
+ const { t } = useTranslation();
+ const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
+ const autoAddBoardName = useBoardName(autoAddBoardId);
+
return (
{reasons.map((reason, i) => (
))}
+ {!canWriteImages && (
+
+
+ {t('parameters.invoke.boardNotWritable', { boardName: autoAddBoardName || autoAddBoardId })}
+
+
+ )}
);
});
diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py
index da2677d1275..12693ba50ef 100644
--- a/tests/app/routers/test_multiuser_authorization.py
+++ b/tests/app/routers/test_multiuser_authorization.py
@@ -21,6 +21,7 @@
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.invocation_services import InvocationServices
from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
from invokeai.app.services.users.users_common import UserCreateRequest
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from invokeai.backend.util.logging import InvokeAILogger
@@ -690,6 +691,111 @@ def test_counts_by_destination_requires_auth(self, enable_multiuser: Any, client
assert r.status_code == status.HTTP_401_UNAUTHORIZED
+# ===========================================================================
+# 6b. Session queue sanitization (cross-user isolation)
+# ===========================================================================
+
+
+class TestSessionQueueSanitization:
+ """Tests that sanitize_queue_item_for_user strips all sensitive fields
+ from queue items viewed by non-owner, non-admin users."""
+
+ @pytest.fixture
+ def _sample_queue_item(self):
+ from invokeai.app.services.shared.graph import Graph, GraphExecutionState
+
+ return SessionQueueItem(
+ item_id=42,
+ status="pending",
+ priority=10,
+ batch_id="batch-abc",
+ origin="workflows",
+ destination="canvas",
+ session_id="sess-123",
+ session=GraphExecutionState(id="sess-123", graph=Graph()),
+ error_type="RuntimeError",
+ error_message="something broke",
+ error_traceback="Traceback ...",
+ created_at="2026-01-01T00:00:00",
+ updated_at="2026-01-01T01:00:00",
+ started_at="2026-01-01T00:30:00",
+ completed_at=None,
+ queue_id="default",
+ user_id="owner-user",
+ user_display_name="Owner Display",
+ user_email="owner@test.com",
+ field_values=None,
+ workflow=None,
+ )
+
+ def test_owner_sees_all_fields(self, _sample_queue_item: SessionQueueItem):
+ from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
+
+ result = sanitize_queue_item_for_user(_sample_queue_item, "owner-user", is_admin=False)
+ assert result.user_id == "owner-user"
+ assert result.user_display_name == "Owner Display"
+ assert result.user_email == "owner@test.com"
+ assert result.batch_id == "batch-abc"
+ assert result.origin == "workflows"
+ assert result.destination == "canvas"
+ assert result.session_id == "sess-123"
+ assert result.priority == 10
+
+ def test_admin_sees_all_fields(self, _sample_queue_item: SessionQueueItem):
+ from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
+
+ result = sanitize_queue_item_for_user(_sample_queue_item, "admin-user", is_admin=True)
+ assert result.user_id == "owner-user"
+ assert result.user_display_name == "Owner Display"
+ assert result.user_email == "owner@test.com"
+ assert result.batch_id == "batch-abc"
+
+ def test_non_owner_sees_only_status_timestamps_errors(self, _sample_queue_item: SessionQueueItem):
+ from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
+
+ result = sanitize_queue_item_for_user(_sample_queue_item, "other-user", is_admin=False)
+
+ # Preserved: item_id, queue_id, status, timestamps
+ assert result.item_id == 42
+ assert result.queue_id == "default"
+ assert result.status == "pending"
+ assert result.created_at == "2026-01-01T00:00:00"
+ assert result.updated_at == "2026-01-01T01:00:00"
+ assert result.started_at == "2026-01-01T00:30:00"
+ assert result.completed_at is None
+
+ # Stripped: errors (may leak file paths, prompts, model names)
+ assert result.error_type is None
+ assert result.error_message is None
+ assert result.error_traceback is None
+
+ # Stripped: user identity
+ assert result.user_id == "redacted"
+ assert result.user_display_name is None
+ assert result.user_email is None
+
+ # Stripped: generation metadata
+ assert result.batch_id == "redacted"
+ assert result.session_id == "redacted"
+ assert result.origin is None
+ assert result.destination is None
+ assert result.priority == 0
+ assert result.field_values is None
+ assert result.retried_from_item_id is None
+ assert result.workflow is None
+ assert result.session.id == "redacted"
+ assert len(result.session.graph.nodes) == 0
+
+ def test_sanitization_does_not_mutate_original(self, _sample_queue_item: SessionQueueItem):
+ from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
+
+ sanitize_queue_item_for_user(_sample_queue_item, "other-user", is_admin=False)
+ # Original should be unchanged
+ assert _sample_queue_item.user_id == "owner-user"
+ assert _sample_queue_item.user_email == "owner@test.com"
+ assert _sample_queue_item.batch_id == "batch-abc"
+
+
# ===========================================================================
# 7. Recall parameters authorization
# ===========================================================================
@@ -705,3 +811,67 @@ def test_get_recall_parameters_requires_auth(self, enable_multiuser: Any, client
def test_update_recall_parameters_requires_auth(self, enable_multiuser: Any, client: TestClient):
r = client.post("/api/v1/recall/default", json={"positive_prompt": "test"})
assert r.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+# ===========================================================================
+# 7b. Recall parameters cross-user isolation
+# ===========================================================================
+
+
+class TestRecallParametersIsolation:
+ """Tests that recall parameters are scoped per-user, not globally by queue_id."""
+
+ def test_user1_write_does_not_leak_to_user2(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ """User1 sets a recall parameter; user2 should not see it in client state."""
+ # user1 writes a recall parameter
+ r = client.post(
+ "/api/v1/recall/default",
+ json={"positive_prompt": "user1 secret prompt"},
+ headers=_auth(user1_token),
+ )
+ assert r.status_code == 200
+
+ # Verify that user1's data is stored under user1's user_id, not the queue_id
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ user2 = mock_invoker.services.users.get_by_email("user2@test.com")
+ assert user1 is not None
+ assert user2 is not None
+
+ # user1 should have the value
+ val = mock_invoker.services.client_state_persistence.get_by_key(user1.user_id, "recall_positive_prompt")
+ assert val is not None
+ assert "user1 secret prompt" in val
+
+ # user2 should NOT have the value
+ val2 = mock_invoker.services.client_state_persistence.get_by_key(user2.user_id, "recall_positive_prompt")
+ assert val2 is None
+
+ def test_two_users_independent_state(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
+ ):
+ """Both users can write recall params independently without overwriting each other."""
+ r1 = client.post(
+ "/api/v1/recall/default",
+ json={"positive_prompt": "prompt from user1"},
+ headers=_auth(user1_token),
+ )
+ assert r1.status_code == 200
+
+ r2 = client.post(
+ "/api/v1/recall/default",
+ json={"positive_prompt": "prompt from user2"},
+ headers=_auth(user2_token),
+ )
+ assert r2.status_code == 200
+
+ user1 = mock_invoker.services.users.get_by_email("user1@test.com")
+ user2 = mock_invoker.services.users.get_by_email("user2@test.com")
+ assert user1 is not None
+ assert user2 is not None
+
+ val1 = mock_invoker.services.client_state_persistence.get_by_key(user1.user_id, "recall_positive_prompt")
+ val2 = mock_invoker.services.client_state_persistence.get_by_key(user2.user_id, "recall_positive_prompt")
+ assert val1 is not None and "prompt from user1" in val1
+ assert val2 is not None and "prompt from user2" in val2
From 797638b0d6d535710d22b0f9ac639d03b5f1f037 Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Thu, 9 Apr 2026 21:31:25 -0400
Subject: [PATCH 032/100] fix(workflow): do not filter default workflows in
multiuser mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem: When categories=['user', 'default'] (or no category filter)
and user_id was set for multiuser scoping, the SQL query became
WHERE category IN ('user', 'default') AND user_id = ?,
which excluded default workflows (owned by "system").
Fix: Changed user_id = ? to (user_id = ? OR category = 'default') in
all 6 occurrences across workflow_records_sqlite.py — in get_many,
counts_by_category, counts_by_tag, and get_all_tags. Default
workflows are now always visible regardless of user scoping.
Tests added (2):
- test_default_workflows_visible_when_listing_user_and_default — categories=['user','default'] includes both
- test_default_workflows_visible_when_no_category_filter — no filter still shows defaults
---
.../session_queue/session_queue_sqlite.py | 2 +-
.../workflow_records_sqlite.py | 12 ++-
.../frontend/web/src/services/api/schema.ts | 30 ++++--
.../routers/test_multiuser_authorization.py | 98 +++++++++++++++++++
4 files changed, 130 insertions(+), 12 deletions(-)
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index 81f1a4601c9..341137432c0 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -768,7 +768,7 @@ def get_queue_item_ids(
user_id: Optional[str] = None,
) -> ItemIdsResult:
with self._db.transaction() as cursor_:
- query = f"""--sql
+ query = """--sql
SELECT item_id
FROM session_queue
WHERE queue_id = ?
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 0e6dfe1b700..1791b3cae62 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -209,7 +209,8 @@ def get_many(
params.extend([wildcard_query, wildcard_query, wildcard_query])
if user_id is not None:
- conditions.append("user_id = ?")
+ # Scope to the given user but always include default workflows
+ conditions.append("(user_id = ? OR category = 'default')")
params.append(user_id)
if is_public is True:
@@ -291,7 +292,8 @@ def counts_by_tag(
base_conditions.append("opened_at IS NULL")
if user_id is not None:
- base_conditions.append("user_id = ?")
+ # Scope to the given user but always include default workflows
+ base_conditions.append("(user_id = ? OR category = 'default')")
base_params.append(user_id)
if is_public is True:
@@ -350,7 +352,8 @@ def counts_by_category(
base_conditions.append("opened_at IS NULL")
if user_id is not None:
- base_conditions.append("user_id = ?")
+ # Scope to the given user but always include default workflows
+ base_conditions.append("(user_id = ? OR category = 'default')")
base_params.append(user_id)
if is_public is True:
@@ -414,7 +417,8 @@ def get_all_tags(
params.extend([category.value for category in categories])
if user_id is not None:
- conditions.append("user_id = ?")
+ # Scope to the given user but always include default workflows
+ conditions.append("(user_id = ? OR category = 'default')")
params.append(user_id)
if is_public is True:
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index d04bea3e6dc..9ec1ed48d4b 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -1049,7 +1049,7 @@ export type paths = {
post?: never;
/**
* Clear Intermediates
- * @description Clears all intermediates
+ * @description Clears all intermediates. Requires admin.
*/
delete: operations["clear_intermediates"];
options?: never;
@@ -1103,7 +1103,11 @@ export type paths = {
};
/**
* Get Image Full
- * @description Gets a full-resolution image file
+ * @description Gets a full-resolution image file.
+ *
+ * This endpoint is intentionally unauthenticated because browsers load images
+ * via
tags which cannot send Bearer tokens. Image names are UUIDs,
+ * providing security through unguessability.
*/
get: operations["get_image_full"];
put?: never;
@@ -1112,7 +1116,11 @@ export type paths = {
options?: never;
/**
* Get Image Full
- * @description Gets a full-resolution image file
+ * @description Gets a full-resolution image file.
+ *
+ * This endpoint is intentionally unauthenticated because browsers load images
+ * via
tags which cannot send Bearer tokens. Image names are UUIDs,
+ * providing security through unguessability.
*/
head: operations["get_image_full_head"];
patch?: never;
@@ -1127,7 +1135,11 @@ export type paths = {
};
/**
* Get Image Thumbnail
- * @description Gets a thumbnail image file
+ * @description Gets a thumbnail image file.
+ *
+ * This endpoint is intentionally unauthenticated because browsers load images
+ * via
tags which cannot send Bearer tokens. Image names are UUIDs,
+ * providing security through unguessability.
*/
get: operations["get_image_thumbnail"];
put?: never;
@@ -1187,7 +1199,7 @@ export type paths = {
post?: never;
/**
* Delete Uncategorized Images
- * @description Deletes all images that are uncategorized
+ * @description Deletes all uncategorized images owned by the current user (or all if admin)
*/
delete: operations["delete_uncategorized_images"];
options?: never;
@@ -1727,7 +1739,7 @@ export type paths = {
};
/**
* Get Queue Item Ids
- * @description Gets all queue item ids that match the given parameters
+ * @description Gets all queue item ids that match the given parameters. Non-admin users only see their own items.
*/
get: operations["get_queue_item_ids"];
put?: never;
@@ -2163,7 +2175,11 @@ export type paths = {
};
/**
* Get Workflow Thumbnail
- * @description Gets a workflow's thumbnail image
+ * @description Gets a workflow's thumbnail image.
+ *
+ * This endpoint is intentionally unauthenticated because browsers load images
+ * via
tags which cannot send Bearer tokens. Workflow IDs are UUIDs,
+ * providing security through unguessability.
*/
get: operations["get_workflow_thumbnail"];
/**
diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py
index 12693ba50ef..2bd47846c55 100644
--- a/tests/app/routers/test_multiuser_authorization.py
+++ b/tests/app/routers/test_multiuser_authorization.py
@@ -576,6 +576,104 @@ def test_images_by_names_filters_unauthorized(
# ===========================================================================
+class TestWorkflowListScoping:
+ """Tests that listing workflows in multiuser mode does not filter out default workflows."""
+
+ def test_default_workflows_visible_when_listing_user_and_default(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str
+ ):
+ """When categories=['user','default'], default workflows must still appear even
+ though user_id_filter is set to the current user (default workflows belong to 'system')."""
+ from invokeai.app.services.workflow_records.workflow_records_common import (
+ Workflow,
+ WorkflowCategory,
+ WorkflowMeta,
+ WorkflowWithoutID,
+ )
+ from invokeai.app.util.misc import uuid_string
+
+ default_wf = WorkflowWithoutID(
+ name="Test Default Workflow",
+ description="A built-in workflow",
+ meta=WorkflowMeta(version="3.0.0", category=WorkflowCategory.Default),
+ nodes=[],
+ edges=[],
+ tags="",
+ author="",
+ contact="",
+ version="1.0.0",
+ notes="",
+ exposedFields=[],
+ form_fields=[],
+ )
+ wf_with_id = Workflow(**default_wf.model_dump(), id=uuid_string())
+ # Insert directly via DB since the create API rejects default workflows
+ with mock_invoker.services.workflow_records._db.transaction() as cursor:
+ cursor.execute(
+ "INSERT INTO workflow_library (workflow_id, workflow, user_id) VALUES (?, ?, ?)",
+ (wf_with_id.id, wf_with_id.model_dump_json(), "system"),
+ )
+
+ # Also create a user workflow via the API
+ _create_workflow(client, user1_token)
+
+ # List with categories=user&categories=default
+ r = client.get(
+ "/api/v1/workflows/?categories=user&categories=default",
+ headers=_auth(user1_token),
+ )
+ assert r.status_code == 200
+ data = r.json()
+ categories_found = {item["category"] for item in data["items"]}
+ assert "default" in categories_found, (
+ f"Default workflows were filtered out. Categories found: {categories_found}"
+ )
+ assert "user" in categories_found
+
+ def test_default_workflows_visible_when_no_category_filter(
+ self, client: TestClient, mock_invoker: Invoker, user1_token: str
+ ):
+ """When no categories filter is given, default workflows should still appear."""
+ from invokeai.app.services.workflow_records.workflow_records_common import (
+ Workflow,
+ WorkflowCategory,
+ WorkflowMeta,
+ WorkflowWithoutID,
+ )
+ from invokeai.app.util.misc import uuid_string
+
+ default_wf = WorkflowWithoutID(
+ name="Another Default Workflow",
+ description="Built-in",
+ meta=WorkflowMeta(version="3.0.0", category=WorkflowCategory.Default),
+ nodes=[],
+ edges=[],
+ tags="",
+ author="",
+ contact="",
+ version="1.0.0",
+ notes="",
+ exposedFields=[],
+ form_fields=[],
+ )
+ wf_with_id = Workflow(**default_wf.model_dump(), id=uuid_string())
+ with mock_invoker.services.workflow_records._db.transaction() as cursor:
+ cursor.execute(
+ "INSERT INTO workflow_library (workflow_id, workflow, user_id) VALUES (?, ?, ?)",
+ (wf_with_id.id, wf_with_id.model_dump_json(), "system"),
+ )
+
+ _create_workflow(client, user1_token)
+
+ r = client.get("/api/v1/workflows/", headers=_auth(user1_token))
+ assert r.status_code == 200
+ data = r.json()
+ categories_found = {item["category"] for item in data["items"]}
+ assert "default" in categories_found, (
+ f"Default workflows were filtered out. Categories found: {categories_found}"
+ )
+
+
class TestWorkflowMutationAuth:
"""Tests for additional workflow mutation endpoints."""
From c3a79a4ea9f2fc3e8aab482a4460e00e90f70717 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 09:20:30 -0500
Subject: [PATCH 033/100] Fix frontend lint on workflow node fixtures
---
.../nodes/store/util/getFirstValidConnection.ts | 6 +-----
.../web/src/features/nodes/store/util/testUtils.ts | 2 ++
.../nodes/store/util/validateConnection.test.ts | 12 +++++++++++-
3 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts
index 851cc2723b9..17b068ad0c5 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/getFirstValidConnection.ts
@@ -9,11 +9,7 @@ import {
import { validateConnection } from 'features/nodes/store/util/validateConnection';
import type { FieldInputTemplate, FieldOutputTemplate } from 'features/nodes/types/field';
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
-import {
- getInvocationNodeInputTemplate,
- isConnectorNode,
- isInvocationNode,
-} from 'features/nodes/types/invocation';
+import { getInvocationNodeInputTemplate, isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation';
/**
*
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
index f480f471de5..67c477408f3 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts
@@ -79,6 +79,7 @@ export const call_saved_workflow: InvocationTemplate = {
version: '1.0.0',
tags: ['workflow', 'saved', 'library'],
description: 'Displays and later executes against a selected saved workflow.',
+ category: 'workflow',
outputType: 'integer_output',
inputs: {
workflow_id: {
@@ -128,6 +129,7 @@ export const workflow_return: InvocationTemplate = {
version: '1.0.0',
tags: ['workflow', 'return', 'output'],
description: 'Defines the explicit collection result returned by a callable workflow.',
+ category: 'workflow',
outputType: 'workflow_return_output',
inputs: {
collection: {
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
index c6d2082ee07..5cd6d1dc4fe 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
@@ -10,7 +10,17 @@ import {
CONNECTOR_OUTPUT_HANDLE,
getConnectorDeletionSpliceConnections,
} from './connectorTopology';
-import { add, buildEdge, buildNode, call_saved_workflow, collect, img_resize, main_model_loader, sub, templates } from './testUtils';
+import {
+ add,
+ buildEdge,
+ buildNode,
+ call_saved_workflow,
+ collect,
+ img_resize,
+ main_model_loader,
+ sub,
+ templates,
+} from './testUtils';
import { validateConnection } from './validateConnection';
const ifTemplate: InvocationTemplate = {
From eacc185c02140b33891600f8d123f9346088e562 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 09:46:46 -0500
Subject: [PATCH 034/100] Add workflow call runtime state scaffolding
---
.../session_processor_default.py | 2 +-
invokeai/app/services/shared/graph.py | 69 +++++++
.../test_session_processor_shutdown.py | 115 ++++++++++++
tests/test_graph_execution_state.py | 177 ++++++++++++++++++
4 files changed, 362 insertions(+), 1 deletion(-)
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index 7159c19e746..f292ea6f96e 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -201,7 +201,7 @@ def _on_after_run_session(self, queue_item: SessionQueueItem) -> None:
# The queue item may have been canceled or failed while the session was running. We should only complete it
# if it is not already canceled or failed.
- if queue_item.status not in ["canceled", "failed"]:
+ if queue_item.status not in ["canceled", "failed"] and queue_item.session.is_complete():
queue_item = self._services.session_queue.complete_queue_item(queue_item.item_id)
# We'll get a GESStatsNotFoundError if we try to log stats for an untracked graph, but in the processor
diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py
index 24c1dd1fe4f..0ce97ecf17c 100644
--- a/invokeai/app/services/shared/graph.py
+++ b/invokeai/app/services/shared/graph.py
@@ -68,6 +68,15 @@ def __str__(self):
PreparedExecState = Literal["pending", "ready", "executed", "skipped"]
+class WorkflowCallFrame(BaseModel):
+ """Represents one workflow-call frame in a nested call chain."""
+
+ prepared_call_node_id: str = Field(description="The prepared exec node id for the call site.")
+ source_call_node_id: str = Field(description="The source graph node id for the call site.")
+ workflow_id: str = Field(description="The saved workflow being called.")
+ depth: int = Field(description="The 1-based depth of this call frame.", ge=1)
+
+
@dataclass
class _PreparedExecNodeMetadata:
"""Cached metadata for a materialized execution node."""
@@ -1714,6 +1723,20 @@ class GraphExecutionState(BaseModel):
# Errors raised when executing nodes
errors: dict[str, str] = Field(description="Errors raised when executing nodes", default_factory=dict)
+ workflow_call_stack: list[WorkflowCallFrame] = Field(
+ description="The nested workflow call stack inherited by this execution state.",
+ default_factory=list,
+ )
+ waiting_workflow_call: Optional[WorkflowCallFrame] = Field(
+ default=None,
+ description="The child workflow call this execution state is currently waiting on, if any.",
+ )
+ max_workflow_call_depth: int = Field(
+ default=4,
+ ge=1,
+ description="The maximum permitted workflow call depth for nested workflow execution.",
+ )
+
# Map of prepared/executed nodes to their original nodes
prepared_source_mapping: dict[str, str] = Field(
description="The map of prepared nodes to original graph nodes",
@@ -1829,6 +1852,7 @@ def _prepare_until_node_ready(self) -> Optional[BaseInvocation]:
"executed_history",
"results",
"errors",
+ "workflow_call_stack",
"prepared_source_mapping",
"source_prepared_mapping",
]
@@ -1847,6 +1871,9 @@ def next(self) -> Optional[BaseInvocation]:
# TODO: enable multiple nodes to execute simultaneously by tracking currently executing nodes
# possibly with a timeout?
+ if self.is_waiting_on_workflow_call():
+ return None
+
# If there are no prepared nodes, prepare some nodes
next_node = self._get_next_node()
if next_node is None:
@@ -1872,6 +1899,8 @@ def set_node_error(self, node_id: str, error: str):
def is_complete(self) -> bool:
"""Returns true if the graph is complete"""
+ if self.is_waiting_on_workflow_call():
+ return False
node_ids = set(self.graph.nx_graph_flat().nodes)
return self.has_error() or all((k in self.executed for k in node_ids))
@@ -1879,6 +1908,46 @@ def has_error(self) -> bool:
"""Returns true if the graph has any errors"""
return len(self.errors) > 0
+ def get_workflow_call_depth(self) -> int:
+ return len(self.workflow_call_stack)
+
+ def is_waiting_on_workflow_call(self) -> bool:
+ return self.waiting_workflow_call is not None
+
+ def build_workflow_call_frame(self, exec_node_id: str, workflow_id: str) -> WorkflowCallFrame:
+ if exec_node_id not in self.execution_graph.nodes:
+ raise NodeNotFoundError(f"Node {exec_node_id} not found in execution graph")
+ if exec_node_id not in self.prepared_source_mapping:
+ raise ValueError(f"Node {exec_node_id} is not a prepared execution node")
+
+ next_depth = self.get_workflow_call_depth() + 1
+ if next_depth > self.max_workflow_call_depth:
+ raise ValueError(
+ f"Maximum workflow call depth exceeded ({self.max_workflow_call_depth}) for workflow '{workflow_id}'"
+ )
+
+ return WorkflowCallFrame(
+ prepared_call_node_id=exec_node_id,
+ source_call_node_id=self.prepared_source_mapping[exec_node_id],
+ workflow_id=workflow_id,
+ depth=next_depth,
+ )
+
+ def begin_waiting_on_workflow_call(self, frame: WorkflowCallFrame) -> None:
+ if self.waiting_workflow_call is not None:
+ raise ValueError("Execution state is already waiting on a workflow call")
+ self.waiting_workflow_call = frame
+
+ def end_waiting_on_workflow_call(self) -> None:
+ self.waiting_workflow_call = None
+
+ def create_child_workflow_execution_state(self, graph: Graph, frame: WorkflowCallFrame) -> "GraphExecutionState":
+ return GraphExecutionState(
+ graph=graph,
+ workflow_call_stack=[*self.workflow_call_stack, frame],
+ max_workflow_call_depth=self.max_workflow_call_depth,
+ )
+
def _create_execution_node(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> list[str]:
return self._materializer().create_execution_node(node_id, iteration_node_map)
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index 7c321510ed8..20350633068 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -5,6 +5,7 @@
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
from invokeai.app.services.session_processor.session_processor_default import DefaultSessionRunner
+from invokeai.app.services.shared.graph import WorkflowCallFrame
from tests.dangerously_run_function_in_subprocess import dangerously_run_function_in_subprocess
@@ -24,6 +25,12 @@ class _DummyStats:
def collect_stats(self, invocation: BaseInvocation, graph_execution_state_id: str):
yield
+ def log_stats(self, graph_execution_state_id: str) -> None:
+ pass
+
+ def reset_stats(self, graph_execution_state_id: str) -> None:
+ pass
+
class _DummyEvents:
def emit_invocation_started(self, queue_item, invocation) -> None:
@@ -83,6 +90,40 @@ def _build_queue_item(invocation: BaseInvocation):
)()
+class _DummySessionQueue:
+ def __init__(self) -> None:
+ self.completed_item_ids: list[int] = []
+ self.session_updates: list[tuple[int, object]] = []
+
+ def set_queue_item_session(self, item_id: int, session):
+ self.session_updates.append((item_id, session))
+ return type("QueueItem", (), {"item_id": item_id, "status": "in_progress", "session": session})()
+
+ def complete_queue_item(self, item_id: int):
+ self.completed_item_ids.append(item_id)
+ return type("QueueItem", (), {"item_id": item_id, "status": "completed"})()
+
+
+class _WaitingSession:
+ def __init__(self) -> None:
+ self.id = "session-id"
+ self.prepared_source_mapping = {}
+ self._next_calls = 0
+ self.waiting_workflow_call = WorkflowCallFrame(
+ prepared_call_node_id="prepared-call",
+ source_call_node_id="source-call",
+ workflow_id="workflow-a",
+ depth=1,
+ )
+
+ def next(self):
+ self._next_calls += 1
+ return None
+
+ def is_complete(self) -> bool:
+ return False
+
+
def test_run_node_propagates_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch) -> None:
runner = _build_runner(monkeypatch)
invocation = KeyboardInterruptInvocation(id="node")
@@ -183,3 +224,77 @@ class DummyConfig:
assert stdout.strip() == ""
assert returncode != 0, stderr
+
+
+def test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+
+ runner = DefaultSessionRunner()
+ runner.start(
+ services=type(
+ "Services",
+ (),
+ {
+ "performance_statistics": _DummyStats(),
+ "events": _DummyEvents(),
+ "logger": _DummyLogger(),
+ "configuration": _DummyConfig(),
+ "session_queue": session_queue,
+ },
+ )(),
+ cancel_event=Event(),
+ )
+
+ session = type("Session", (), {"id": "session-id", "is_complete": lambda self: False})()
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ },
+ )()
+
+ runner._on_after_run_session(queue_item=queue_item)
+
+ assert session_queue.session_updates == [(1, session)]
+ assert session_queue.completed_item_ids == []
+
+
+def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner = DefaultSessionRunner()
+ runner.start(
+ services=type(
+ "Services",
+ (),
+ {
+ "performance_statistics": _DummyStats(),
+ "events": _DummyEvents(),
+ "logger": _DummyLogger(),
+ "configuration": _DummyConfig(),
+ "session_queue": session_queue,
+ },
+ )(),
+ cancel_event=Event(),
+ )
+
+ session = _WaitingSession()
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ assert session._next_calls == 1
+ assert session_queue.session_updates == [(1, session)]
+ assert session_queue.completed_item_ids == []
diff --git a/tests/test_graph_execution_state.py b/tests/test_graph_execution_state.py
index ffd0ca1559d..c2ef8406558 100644
--- a/tests/test_graph_execution_state.py
+++ b/tests/test_graph_execution_state.py
@@ -13,6 +13,7 @@
Graph,
GraphExecutionState,
IterateInvocation,
+ WorkflowCallFrame,
)
# This import must happen before other invoke imports or test in other files(!!) break
@@ -90,6 +91,182 @@ def test_graph_is_not_complete(simple_graph: Graph):
assert not g.is_complete()
+def test_graph_waiting_on_workflow_call_blocks_other_ready_nodes():
+ graph = Graph()
+ graph.add_node(PromptTestInvocation(id="prompt_a", prompt="a"))
+ graph.add_node(PromptTestInvocation(id="prompt_b", prompt="b"))
+
+ g = GraphExecutionState(graph=graph)
+
+ first = g.next()
+ assert first is not None
+
+ waiting_frame = g.build_workflow_call_frame(exec_node_id=first.id, workflow_id="workflow-a")
+ g.begin_waiting_on_workflow_call(waiting_frame)
+
+ assert g.next() is None
+ assert not g.is_complete()
+ assert g.is_waiting_on_workflow_call()
+
+
+def test_graph_build_workflow_call_frame_uses_prepared_and_source_ids():
+ g = GraphExecutionState(graph=Graph())
+ g.execution_graph.add_node(PromptTestInvocation(id="prepared-call", prompt="a"))
+ g.prepared_source_mapping["prepared-call"] = "source-call"
+
+ frame = g.build_workflow_call_frame(exec_node_id="prepared-call", workflow_id="workflow-a")
+
+ assert frame.prepared_call_node_id == "prepared-call"
+ assert frame.source_call_node_id == "source-call"
+ assert frame.workflow_id == "workflow-a"
+ assert frame.depth == 1
+
+
+def test_graph_build_workflow_call_frame_rejects_depth_over_limit():
+ g = GraphExecutionState(
+ graph=Graph(),
+ workflow_call_stack=[
+ WorkflowCallFrame(
+ prepared_call_node_id=f"prepared-{i}",
+ source_call_node_id=f"source-{i}",
+ workflow_id=f"workflow-{i}",
+ depth=i + 1,
+ )
+ for i in range(4)
+ ],
+ )
+ g.execution_graph.add_node(PromptTestInvocation(id="prepared-call", prompt="a"))
+ g.prepared_source_mapping["prepared-call"] = "source-call"
+
+ with pytest.raises(ValueError, match="Maximum workflow call depth"):
+ g.build_workflow_call_frame(exec_node_id="prepared-call", workflow_id="workflow-a")
+
+
+def test_graph_execution_state_serializes_workflow_call_state():
+ g = GraphExecutionState(graph=Graph())
+ g.execution_graph.add_node(PromptTestInvocation(id="prepared-call", prompt="a"))
+ g.prepared_source_mapping["prepared-call"] = "source-call"
+
+ frame = g.build_workflow_call_frame(exec_node_id="prepared-call", workflow_id="workflow-a")
+ g.workflow_call_stack.append(frame)
+ g.begin_waiting_on_workflow_call(frame)
+
+ restored = GraphExecutionState.model_validate(g.model_dump(warnings=False))
+
+ assert restored.workflow_call_stack == [frame]
+ assert restored.waiting_workflow_call == frame
+ assert restored.max_workflow_call_depth == 4
+
+
+def test_graph_waiting_on_workflow_call_blocks_until_suspended_node_is_completed():
+ graph = Graph()
+ graph.add_node(PromptTestInvocation(id="prompt_a", prompt="a"))
+ graph.add_node(PromptTestInvocation(id="prompt_b", prompt="b"))
+
+ g = GraphExecutionState(graph=graph)
+
+ first = g.next()
+ assert first is not None
+
+ waiting_frame = g.build_workflow_call_frame(exec_node_id=first.id, workflow_id="workflow-a")
+ g.begin_waiting_on_workflow_call(waiting_frame)
+ assert g.next() is None
+
+ g.end_waiting_on_workflow_call()
+ g.complete(first.id, first.invoke(Mock(InvocationContext)))
+
+ resumed = g.next()
+ assert resumed is not None
+ assert resumed.id != first.id
+ assert g.prepared_source_mapping[resumed.id] == "prompt_b"
+
+
+def test_graph_begin_waiting_on_workflow_call_rejects_double_entry():
+ g = GraphExecutionState(graph=Graph())
+ g.execution_graph.add_node(PromptTestInvocation(id="prepared-call", prompt="a"))
+ g.prepared_source_mapping["prepared-call"] = "source-call"
+
+ first_frame = g.build_workflow_call_frame(exec_node_id="prepared-call", workflow_id="workflow-a")
+ g.begin_waiting_on_workflow_call(first_frame)
+
+ with pytest.raises(ValueError, match="already waiting"):
+ g.begin_waiting_on_workflow_call(first_frame)
+
+
+def test_graph_build_workflow_call_frame_rejects_missing_execution_node():
+ g = GraphExecutionState(graph=Graph())
+
+ with pytest.raises(Exception, match="not found in execution graph"):
+ g.build_workflow_call_frame(exec_node_id="missing-node", workflow_id="workflow-a")
+
+
+def test_graph_build_workflow_call_frame_rejects_unprepared_execution_node():
+ g = GraphExecutionState(graph=Graph())
+ g.execution_graph.add_node(PromptTestInvocation(id="prepared-call", prompt="a"))
+
+ with pytest.raises(ValueError, match="not a prepared execution node"):
+ g.build_workflow_call_frame(exec_node_id="prepared-call", workflow_id="workflow-a")
+
+
+def test_graph_child_workflow_execution_state_inherits_stack_and_isolates_runtime_state():
+ parent_graph = Graph()
+ child_graph = Graph()
+
+ parent = GraphExecutionState(graph=parent_graph)
+ parent.execution_graph.add_node(PromptTestInvocation(id="prepared-parent", prompt="a"))
+ parent.prepared_source_mapping["prepared-parent"] = "source-parent"
+ parent.results["prepared-parent"] = PromptTestInvocation(id="result-node", prompt="existing").invoke(
+ Mock(InvocationContext)
+ )
+ parent.executed.add("prepared-parent")
+
+ root_frame = parent.build_workflow_call_frame(exec_node_id="prepared-parent", workflow_id="workflow-a")
+ parent.workflow_call_stack.append(root_frame)
+
+ parent.execution_graph.add_node(PromptTestInvocation(id="prepared-child", prompt="b"))
+ parent.prepared_source_mapping["prepared-child"] = "source-child"
+ child_frame = parent.build_workflow_call_frame(exec_node_id="prepared-child", workflow_id="workflow-b")
+
+ child_state = parent.create_child_workflow_execution_state(graph=child_graph, frame=child_frame)
+
+ assert child_state.graph == child_graph
+ assert child_state.workflow_call_stack == [root_frame, child_frame]
+ assert child_state.max_workflow_call_depth == parent.max_workflow_call_depth
+ assert child_state.waiting_workflow_call is None
+ assert child_state.results == {}
+ assert child_state.executed == set()
+
+
+def test_graph_execution_state_serializes_recursive_workflow_call_stack():
+ g = GraphExecutionState(
+ graph=Graph(),
+ workflow_call_stack=[
+ WorkflowCallFrame(
+ prepared_call_node_id="prepared-a",
+ source_call_node_id="source-a",
+ workflow_id="workflow-a",
+ depth=1,
+ ),
+ WorkflowCallFrame(
+ prepared_call_node_id="prepared-b",
+ source_call_node_id="source-b",
+ workflow_id="workflow-b",
+ depth=2,
+ ),
+ WorkflowCallFrame(
+ prepared_call_node_id="prepared-a-2",
+ source_call_node_id="source-a-2",
+ workflow_id="workflow-a",
+ depth=3,
+ ),
+ ],
+ )
+
+ restored = GraphExecutionState.model_validate(g.model_dump(warnings=False))
+
+ assert restored.workflow_call_stack == g.workflow_call_stack
+
+
# TODO: test completion with iterators/subgraphs
From e71c140ac8079342794e111e19cdc39be3e6ca03 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 10:05:56 -0500
Subject: [PATCH 035/100] Add workflow call runner boundary handling
---
.../app/invocations/call_saved_workflow.py | 7 +-
.../session_processor_default.py | 7 +
.../test_session_processor_shutdown.py | 255 +++++++++++++++++-
3 files changed, 263 insertions(+), 6 deletions(-)
diff --git a/invokeai/app/invocations/call_saved_workflow.py b/invokeai/app/invocations/call_saved_workflow.py
index 1768caf5c9c..fad7dd156a9 100644
--- a/invokeai/app/invocations/call_saved_workflow.py
+++ b/invokeai/app/invocations/call_saved_workflow.py
@@ -23,7 +23,7 @@ class CallSavedWorkflowInvocation(BaseInvocation):
ui_type=UIType.SavedWorkflow,
)
- def invoke(self, context: InvocationContext) -> IntegerOutput:
+ def validate_selected_workflow(self, context: InvocationContext):
if not self.workflow_id:
raise ValueError("A saved workflow must be selected before executing call_saved_workflow.")
@@ -42,4 +42,9 @@ def invoke(self, context: InvocationContext) -> IntegerOutput:
if not (is_default or is_owner or workflow_record.is_public or is_admin):
raise ValueError(f"The selected saved workflow '{self.workflow_id}' is not accessible to this user.")
+ return workflow_record
+
+ def invoke(self, context: InvocationContext) -> IntegerOutput:
+ self.validate_selected_workflow(context)
+
return IntegerOutput(value=0)
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index f292ea6f96e..9f59c8e5ed1 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -6,6 +6,7 @@
from typing import Optional
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput
+from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
from invokeai.app.services.events.events_common import (
BatchEnqueuedEvent,
FastAPIEvent,
@@ -126,6 +127,12 @@ def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem):
is_canceled=self._is_canceled,
)
+ if isinstance(invocation, CallSavedWorkflowInvocation):
+ invocation.validate_selected_workflow(context)
+ call_frame = queue_item.session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
+ queue_item.session.begin_waiting_on_workflow_call(call_frame)
+ return
+
# Invoke the node
output = invocation.invoke_internal(context=context, services=self._services)
# Save output and history
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index 20350633068..d79517a6dc7 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -1,12 +1,17 @@
from contextlib import contextmanager
from threading import Event
+from types import SimpleNamespace
import pytest
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
+from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
+from invokeai.app.invocations.math import AddInvocation
from invokeai.app.services.session_processor.session_processor_default import DefaultSessionRunner
-from invokeai.app.services.shared.graph import WorkflowCallFrame
+from invokeai.app.services.shared.graph import Graph, GraphExecutionState, WorkflowCallFrame
+from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory
from tests.dangerously_run_function_in_subprocess import dangerously_run_function_in_subprocess
+from tests.test_nodes import create_edge
@invocation_output("test_interrupt_output")
@@ -33,14 +38,19 @@ def reset_stats(self, graph_execution_state_id: str) -> None:
class _DummyEvents:
+ def __init__(self) -> None:
+ self.started: list[tuple[object, object]] = []
+ self.completed: list[tuple[object, object, object]] = []
+ self.errors: list[tuple[object, object, str, str, str]] = []
+
def emit_invocation_started(self, queue_item, invocation) -> None:
- pass
+ self.started.append((queue_item, invocation))
def emit_invocation_complete(self, invocation, queue_item, output) -> None:
- pass
+ self.completed.append((invocation, queue_item, output))
def emit_invocation_error(self, queue_item, invocation, error_type, error_message, error_traceback) -> None:
- pass
+ self.errors.append((queue_item, invocation, error_type, error_message, error_traceback))
class _DummyLogger:
@@ -53,6 +63,21 @@ def error(self, msg) -> None:
class _DummyConfig:
node_cache_size = 0
+ multiuser = False
+
+
+class _DummyWorkflowRecords:
+ def get(self, workflow_id: str):
+ return SimpleNamespace(
+ user_id="user-1",
+ is_public=False,
+ workflow=SimpleNamespace(meta=SimpleNamespace(category=WorkflowCategory.User)),
+ )
+
+
+class _DummyUsers:
+ def get(self, user_id: str):
+ return None
def _build_runner(monkeypatch: pytest.MonkeyPatch) -> DefaultSessionRunner:
@@ -78,6 +103,33 @@ def _build_runner(monkeypatch: pytest.MonkeyPatch) -> DefaultSessionRunner:
return runner
+def _build_workflow_runner(monkeypatch: pytest.MonkeyPatch, session_queue=None):
+ monkeypatch.setattr(
+ "invokeai.app.services.session_processor.session_processor_default.build_invocation_context",
+ lambda data, services, is_canceled: SimpleNamespace(_services=services, _data=data),
+ )
+
+ events = _DummyEvents()
+ runner = DefaultSessionRunner()
+ runner.start(
+ services=type(
+ "Services",
+ (),
+ {
+ "performance_statistics": _DummyStats(),
+ "events": events,
+ "logger": _DummyLogger(),
+ "configuration": _DummyConfig(),
+ "workflow_records": _DummyWorkflowRecords(),
+ "users": _DummyUsers(),
+ "session_queue": session_queue or _DummySessionQueue(),
+ },
+ )(),
+ cancel_event=Event(),
+ )
+ return runner, events
+
+
def _build_queue_item(invocation: BaseInvocation):
return type(
"QueueItem",
@@ -94,6 +146,7 @@ class _DummySessionQueue:
def __init__(self) -> None:
self.completed_item_ids: list[int] = []
self.session_updates: list[tuple[int, object]] = []
+ self.failed_item_ids: list[int] = []
def set_queue_item_session(self, item_id: int, session):
self.session_updates.append((item_id, session))
@@ -101,7 +154,24 @@ def set_queue_item_session(self, item_id: int, session):
def complete_queue_item(self, item_id: int):
self.completed_item_ids.append(item_id)
- return type("QueueItem", (), {"item_id": item_id, "status": "completed"})()
+ session = self.session_updates[-1][1]
+ return type("QueueItem", (), {"item_id": item_id, "status": "completed", "session": session})()
+
+ def fail_queue_item(self, item_id: int, error_type: str, error_message: str, error_traceback: str):
+ self.failed_item_ids.append(item_id)
+ session = self.session_updates[-1][1]
+ return type(
+ "QueueItem",
+ (),
+ {
+ "item_id": item_id,
+ "status": "failed",
+ "session": session,
+ "error_type": error_type,
+ "error_message": error_message,
+ "error_traceback": error_traceback,
+ },
+ )()
class _WaitingSession:
@@ -124,6 +194,34 @@ def is_complete(self) -> bool:
return False
+class _WorkflowCallBoundarySession:
+ def __init__(self, invocation_id: str) -> None:
+ self.id = "session-id"
+ self.prepared_source_mapping = {invocation_id: "source-call"}
+ self.completed: list[tuple[str, object]] = []
+ self.frames: list[WorkflowCallFrame] = []
+ self.waiting: WorkflowCallFrame | None = None
+
+ def build_workflow_call_frame(self, exec_node_id: str, workflow_id: str) -> WorkflowCallFrame:
+ frame = WorkflowCallFrame(
+ prepared_call_node_id=exec_node_id,
+ source_call_node_id=self.prepared_source_mapping[exec_node_id],
+ workflow_id=workflow_id,
+ depth=1,
+ )
+ self.frames.append(frame)
+ return frame
+
+ def begin_waiting_on_workflow_call(self, frame: WorkflowCallFrame) -> None:
+ self.waiting = frame
+
+ def complete(self, node_id: str, output) -> None:
+ self.completed.append((node_id, output))
+
+ def is_waiting_on_workflow_call(self) -> bool:
+ return self.waiting is not None
+
+
def test_run_node_propagates_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch) -> None:
runner = _build_runner(monkeypatch)
invocation = KeyboardInterruptInvocation(id="node")
@@ -263,6 +361,39 @@ def test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch:
assert session_queue.completed_item_ids == []
+def test_run_node_transitions_call_saved_workflow_into_waiting_state(monkeypatch: pytest.MonkeyPatch) -> None:
+ runner, events = _build_workflow_runner(monkeypatch)
+ invocation = CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a")
+ session = _WorkflowCallBoundarySession(invocation.id)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "session_id": "test-session",
+ "user_id": "user-1",
+ "session": session,
+ },
+ )()
+
+ monkeypatch.setattr(
+ CallSavedWorkflowInvocation,
+ "invoke_internal",
+ lambda self, context, services: (_ for _ in ()).throw(AssertionError("invoke_internal should not be called")),
+ )
+
+ runner.run_node(invocation=invocation, queue_item=queue_item)
+
+ assert len(session.frames) == 1
+ assert session.waiting == session.frames[0]
+ assert session.frames[0].prepared_call_node_id == invocation.id
+ assert session.frames[0].workflow_id == "workflow-a"
+ assert session.completed == []
+ assert len(events.started) == 1
+ assert events.completed == []
+ assert events.errors == []
+
+
def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner = DefaultSessionRunner()
@@ -298,3 +429,117 @@ def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch:
assert session._next_calls == 1
assert session_queue.session_updates == [(1, session)]
assert session_queue.completed_item_ids == []
+
+
+def test_run_pauses_on_call_saved_workflow_and_does_not_run_downstream_nodes(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ graph.add_node(AddInvocation(id="downstream-add", b=2))
+ graph.add_edge(create_edge("call-node", "value", "downstream-add", "a"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ assert session.is_waiting_on_workflow_call()
+ assert session.results == {}
+ assert "downstream-add" not in session.executed
+ assert len(events.started) == 1
+ assert events.started[0][1].get_type() == "call_saved_workflow"
+ assert events.completed == []
+ assert events.errors == []
+ assert session_queue.completed_item_ids == []
+ assert session_queue.session_updates == [(1, session)]
+
+
+def test_run_fails_call_saved_workflow_with_invalid_selection_without_entering_waiting_state(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id=""))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ assert not session.is_waiting_on_workflow_call()
+ assert session.has_error()
+ assert session_queue.failed_item_ids == [1]
+ assert len(events.started) == 1
+ assert events.completed == []
+ assert len(events.errors) == 1
+ assert events.errors[0][2] == "ValueError"
+
+
+def test_run_fails_call_saved_workflow_when_depth_limit_is_exceeded(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+
+ session = GraphExecutionState(
+ graph=graph,
+ workflow_call_stack=[
+ WorkflowCallFrame(
+ prepared_call_node_id=f"prepared-{i}",
+ source_call_node_id=f"source-{i}",
+ workflow_id=f"workflow-{i}",
+ depth=i + 1,
+ )
+ for i in range(4)
+ ],
+ )
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ assert not session.is_waiting_on_workflow_call()
+ assert session.has_error()
+ assert session_queue.failed_item_ids == [1]
+ assert len(events.started) == 1
+ assert events.completed == []
+ assert len(events.errors) == 1
+ assert events.errors[0][2] == "ValueError"
From 75c3f9259cbe2c45db668915563d2a8ea48f9b8a Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 10:17:25 -0500
Subject: [PATCH 036/100] Create child workflow execution state on call
boundary
---
.../session_processor_default.py | 6 +-
invokeai/app/services/shared/graph.py | 10 ++
.../services/shared/workflow_graph_builder.py | 169 ++++++++++++++++++
.../test_session_processor_shutdown.py | 168 ++++++++++++++++-
.../services/test_workflow_graph_builder.py | 115 ++++++++++++
5 files changed, 460 insertions(+), 8 deletions(-)
create mode 100644 invokeai/app/services/shared/workflow_graph_builder.py
create mode 100644 tests/app/services/test_workflow_graph_builder.py
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index 9f59c8e5ed1..edf4865b70b 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -31,6 +31,7 @@
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem, SessionQueueItemNotFoundError
from invokeai.app.services.shared.graph import NodeInputError
from invokeai.app.services.shared.invocation_context import InvocationContextData, build_invocation_context
+from invokeai.app.services.shared.workflow_graph_builder import build_graph_from_workflow
from invokeai.app.util.profiler import Profiler
@@ -128,9 +129,12 @@ def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem):
)
if isinstance(invocation, CallSavedWorkflowInvocation):
- invocation.validate_selected_workflow(context)
+ workflow_record = invocation.validate_selected_workflow(context)
call_frame = queue_item.session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
+ child_graph = build_graph_from_workflow(workflow_record.workflow.model_dump())
+ child_session = queue_item.session.create_child_workflow_execution_state(child_graph, call_frame)
queue_item.session.begin_waiting_on_workflow_call(call_frame)
+ queue_item.session.attach_waiting_workflow_call_child_session(child_session)
return
# Invoke the node
diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py
index 0ce97ecf17c..81283119a67 100644
--- a/invokeai/app/services/shared/graph.py
+++ b/invokeai/app/services/shared/graph.py
@@ -1731,6 +1731,10 @@ class GraphExecutionState(BaseModel):
default=None,
description="The child workflow call this execution state is currently waiting on, if any.",
)
+ waiting_workflow_call_child_session: Optional["GraphExecutionState"] = Field(
+ default=None,
+ description="The child workflow execution state spawned by the current waiting workflow call, if any.",
+ )
max_workflow_call_depth: int = Field(
default=4,
ge=1,
@@ -1938,8 +1942,14 @@ def begin_waiting_on_workflow_call(self, frame: WorkflowCallFrame) -> None:
raise ValueError("Execution state is already waiting on a workflow call")
self.waiting_workflow_call = frame
+ def attach_waiting_workflow_call_child_session(self, child_session: "GraphExecutionState") -> None:
+ if self.waiting_workflow_call is None:
+ raise ValueError("Execution state must be waiting on a workflow call before attaching a child session")
+ self.waiting_workflow_call_child_session = child_session
+
def end_waiting_on_workflow_call(self) -> None:
self.waiting_workflow_call = None
+ self.waiting_workflow_call_child_session = None
def create_child_workflow_execution_state(self, graph: Graph, frame: WorkflowCallFrame) -> "GraphExecutionState":
return GraphExecutionState(
diff --git a/invokeai/app/services/shared/workflow_graph_builder.py b/invokeai/app/services/shared/workflow_graph_builder.py
new file mode 100644
index 00000000000..f6aced09bef
--- /dev/null
+++ b/invokeai/app/services/shared/workflow_graph_builder.py
@@ -0,0 +1,169 @@
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from invokeai.app.services.shared.graph import Edge, EdgeConnection, Graph
+
+CONNECTOR_INPUT_HANDLE = "in"
+CONNECTOR_OUTPUT_HANDLE = "out"
+
+
+def _is_mapping(value: Any) -> bool:
+ return isinstance(value, Mapping)
+
+
+def _is_invocation_node(node: Any) -> bool:
+ return _is_mapping(node) and node.get("type") == "invocation" and _is_mapping(node.get("data"))
+
+
+def _is_connector_node(node: Any) -> bool:
+ return _is_mapping(node) and node.get("type") == "connector"
+
+
+def _get_default_edges(workflow_edges: Sequence[Any]) -> list[Mapping[str, Any]]:
+ return [edge for edge in workflow_edges if _is_mapping(edge) and edge.get("type") == "default"]
+
+
+def _get_connector_input_edge(
+ connector_id: str, workflow_edges: Sequence[Mapping[str, Any]]
+) -> Mapping[str, Any] | None:
+ return next(
+ (
+ edge
+ for edge in workflow_edges
+ if edge.get("target") == connector_id and edge.get("targetHandle") == CONNECTOR_INPUT_HANDLE
+ ),
+ None,
+ )
+
+
+def _resolve_connector_source(
+ connector_id: str, workflow_nodes: dict[str, Mapping[str, Any]], workflow_edges: Sequence[Mapping[str, Any]]
+) -> tuple[str, str] | None:
+ visited: set[str] = set()
+
+ def resolve(node_id: str) -> tuple[str, str] | None:
+ if node_id in visited:
+ return None
+ visited.add(node_id)
+
+ incoming_edge = _get_connector_input_edge(node_id, workflow_edges)
+ if incoming_edge is None:
+ return None
+
+ source_id = incoming_edge.get("source")
+ source_handle = incoming_edge.get("sourceHandle")
+ if not isinstance(source_id, str) or not isinstance(source_handle, str):
+ return None
+
+ source_node = workflow_nodes.get(source_id)
+ if source_node is None:
+ return None
+
+ if _is_invocation_node(source_node):
+ return (source_id, source_handle)
+
+ if _is_connector_node(source_node):
+ return resolve(source_id)
+
+ return None
+
+ return resolve(connector_id)
+
+
+def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph:
+ workflow_nodes_raw = workflow.get("nodes", [])
+ workflow_edges_raw = workflow.get("edges", [])
+
+ workflow_nodes = {
+ node["id"]: node for node in workflow_nodes_raw if _is_mapping(node) and isinstance(node.get("id"), str)
+ }
+ default_edges = _get_default_edges(workflow_edges_raw if isinstance(workflow_edges_raw, Sequence) else [])
+
+ parsed_nodes: dict[str, dict[str, Any]] = {}
+ for node in workflow_nodes.values():
+ if not _is_invocation_node(node):
+ continue
+
+ data = node["data"]
+ node_id = data.get("id")
+ node_type = data.get("type")
+ if not isinstance(node_id, str) or not isinstance(node_type, str):
+ continue
+
+ graph_node: dict[str, Any] = {
+ "id": node_id,
+ "type": node_type,
+ "use_cache": data.get("useCache", False),
+ "is_intermediate": data.get("isIntermediate", False),
+ }
+
+ inputs = data.get("inputs", {})
+ if _is_mapping(inputs):
+ for field_name, field_value in inputs.items():
+ if not isinstance(field_name, str) or not _is_mapping(field_value):
+ continue
+ graph_node[field_name] = field_value.get("value")
+
+ parsed_nodes[node_id] = graph_node
+
+ parsed_edges: list[dict[str, dict[str, str]]] = []
+ seen_edges: set[tuple[str, str, str, str]] = set()
+
+ for edge in default_edges:
+ source_id = edge.get("source")
+ target_id = edge.get("target")
+ source_handle = edge.get("sourceHandle")
+ target_handle = edge.get("targetHandle")
+ if not all(isinstance(v, str) for v in (source_id, target_id, source_handle, target_handle)):
+ continue
+
+ target_node = workflow_nodes.get(target_id)
+ if not _is_invocation_node(target_node):
+ continue
+
+ source_node = workflow_nodes.get(source_id)
+ resolved_source: tuple[str, str] | None = None
+ if _is_invocation_node(source_node):
+ resolved_source = (source_id, source_handle)
+ elif _is_connector_node(source_node):
+ resolved_source = _resolve_connector_source(source_id, workflow_nodes, default_edges)
+
+ if resolved_source is None:
+ continue
+
+ resolved_source_id, resolved_source_handle = resolved_source
+ edge_key = (resolved_source_id, resolved_source_handle, target_id, target_handle)
+ if edge_key in seen_edges:
+ continue
+ seen_edges.add(edge_key)
+
+ parsed_edges.append(
+ {
+ "source": {
+ "node_id": resolved_source_id,
+ "field": resolved_source_handle,
+ },
+ "destination": {
+ "node_id": target_id,
+ "field": target_handle,
+ },
+ }
+ )
+
+ for edge in parsed_edges:
+ destination_node_id = edge["destination"]["node_id"]
+ destination_field = edge["destination"]["field"]
+ parsed_nodes[destination_node_id].pop(destination_field, None)
+
+ return Graph.model_validate(
+ {
+ "nodes": parsed_nodes,
+ "edges": [
+ Edge(
+ source=EdgeConnection(**edge["source"]),
+ destination=EdgeConnection(**edge["destination"]),
+ )
+ for edge in parsed_edges
+ ],
+ }
+ )
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index d79517a6dc7..2517ddd1390 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -67,11 +67,88 @@ class _DummyConfig:
class _DummyWorkflowRecords:
+ def __init__(self) -> None:
+ self.return_invalid_workflow = False
+
def get(self, workflow_id: str):
+ workflow = SimpleNamespace(
+ name="Child Workflow",
+ author="Tester",
+ description="",
+ version="1.0.0",
+ contact="",
+ tags="",
+ notes="",
+ exposedFields=[],
+ meta=SimpleNamespace(category=WorkflowCategory.User),
+ form=None,
+ nodes=[
+ {
+ "id": "child-add",
+ "type": "invocation",
+ "position": {"x": 0, "y": 0},
+ "data": {
+ "id": "child-add",
+ "type": "add",
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "isOpen": True,
+ "isIntermediate": False,
+ "useCache": True,
+ "dynamicInputTemplates": {},
+ "inputs": {
+ "a": {"value": 1},
+ "b": {"value": 2},
+ },
+ },
+ }
+ ],
+ edges=[],
+ )
+ workflow.model_dump = lambda: {
+ "name": workflow.name,
+ "author": workflow.author,
+ "description": workflow.description,
+ "version": workflow.version,
+ "contact": workflow.contact,
+ "tags": workflow.tags,
+ "notes": workflow.notes,
+ "exposedFields": workflow.exposedFields,
+ "meta": {"category": workflow.meta.category, "version": "1.0.0"},
+ "nodes": workflow.nodes,
+ "edges": workflow.edges,
+ "form": workflow.form,
+ }
+ if self.return_invalid_workflow:
+ workflow.model_dump = lambda: {
+ "name": workflow.name,
+ "author": workflow.author,
+ "description": workflow.description,
+ "version": workflow.version,
+ "contact": workflow.contact,
+ "tags": workflow.tags,
+ "notes": workflow.notes,
+ "exposedFields": workflow.exposedFields,
+ "meta": {"category": workflow.meta.category, "version": "1.0.0"},
+ "nodes": workflow.nodes,
+ "edges": [
+ {
+ "id": "edge-invalid",
+ "type": "default",
+ "source": "child-add",
+ "sourceHandle": "value",
+ "target": "child-add",
+ "targetHandle": "missing_input",
+ }
+ ],
+ "form": workflow.form,
+ }
return SimpleNamespace(
user_id="user-1",
is_public=False,
- workflow=SimpleNamespace(meta=SimpleNamespace(category=WorkflowCategory.User)),
+ workflow=workflow,
)
@@ -111,6 +188,7 @@ def _build_workflow_runner(monkeypatch: pytest.MonkeyPatch, session_queue=None):
events = _DummyEvents()
runner = DefaultSessionRunner()
+ workflow_records = _DummyWorkflowRecords()
runner.start(
services=type(
"Services",
@@ -120,14 +198,14 @@ def _build_workflow_runner(monkeypatch: pytest.MonkeyPatch, session_queue=None):
"events": events,
"logger": _DummyLogger(),
"configuration": _DummyConfig(),
- "workflow_records": _DummyWorkflowRecords(),
+ "workflow_records": workflow_records,
"users": _DummyUsers(),
"session_queue": session_queue or _DummySessionQueue(),
},
)(),
cancel_event=Event(),
)
- return runner, events
+ return runner, events, workflow_records
def _build_queue_item(invocation: BaseInvocation):
@@ -201,6 +279,8 @@ def __init__(self, invocation_id: str) -> None:
self.completed: list[tuple[str, object]] = []
self.frames: list[WorkflowCallFrame] = []
self.waiting: WorkflowCallFrame | None = None
+ self.waiting_workflow_call_child_session = None
+ self.errors: dict[str, str] = {}
def build_workflow_call_frame(self, exec_node_id: str, workflow_id: str) -> WorkflowCallFrame:
frame = WorkflowCallFrame(
@@ -215,12 +295,21 @@ def build_workflow_call_frame(self, exec_node_id: str, workflow_id: str) -> Work
def begin_waiting_on_workflow_call(self, frame: WorkflowCallFrame) -> None:
self.waiting = frame
+ def create_child_workflow_execution_state(self, graph: Graph, frame: WorkflowCallFrame):
+ return GraphExecutionState(graph=graph, workflow_call_stack=[frame])
+
+ def attach_waiting_workflow_call_child_session(self, child_session: GraphExecutionState) -> None:
+ self.waiting_workflow_call_child_session = child_session
+
def complete(self, node_id: str, output) -> None:
self.completed.append((node_id, output))
def is_waiting_on_workflow_call(self) -> bool:
return self.waiting is not None
+ def set_node_error(self, node_id: str, error: str) -> None:
+ self.errors[node_id] = error
+
def test_run_node_propagates_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch) -> None:
runner = _build_runner(monkeypatch)
@@ -362,7 +451,7 @@ def test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch:
def test_run_node_transitions_call_saved_workflow_into_waiting_state(monkeypatch: pytest.MonkeyPatch) -> None:
- runner, events = _build_workflow_runner(monkeypatch)
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch)
invocation = CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a")
session = _WorkflowCallBoundarySession(invocation.id)
queue_item = type(
@@ -435,7 +524,7 @@ def test_run_pauses_on_call_saved_workflow_and_does_not_run_downstream_nodes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_queue = _DummySessionQueue()
- runner, events = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
@@ -468,11 +557,76 @@ def test_run_pauses_on_call_saved_workflow_and_does_not_run_downstream_nodes(
assert session_queue.session_updates == [(1, session)]
+def test_run_node_creates_child_execution_state_for_waiting_workflow_call(monkeypatch: pytest.MonkeyPatch) -> None:
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ session = GraphExecutionState(graph=graph)
+ invocation = session.next()
+ assert invocation is not None
+
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ "session": session,
+ },
+ )()
+
+ runner.run_node(invocation=invocation, queue_item=queue_item)
+
+ assert session.is_waiting_on_workflow_call()
+ assert session.waiting_workflow_call_child_session is not None
+ assert session.waiting_workflow_call_child_session.graph.nodes["child-add"].get_type() == "add"
+ assert session.waiting_workflow_call_child_session.workflow_call_stack == [session.waiting_workflow_call]
+ assert len(events.started) == 1
+ assert events.completed == []
+ assert events.errors == []
+
+
+def test_run_fails_call_saved_workflow_when_child_workflow_graph_cannot_be_built(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ workflow_records.return_invalid_workflow = True
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ assert not session.is_waiting_on_workflow_call()
+ assert session.waiting_workflow_call_child_session is None
+ assert session.has_error()
+ assert session_queue.failed_item_ids == [1]
+ assert len(events.started) == 1
+ assert events.completed == []
+ assert len(events.errors) == 1
+
+
def test_run_fails_call_saved_workflow_with_invalid_selection_without_entering_waiting_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_queue = _DummySessionQueue()
- runner, events = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id=""))
@@ -505,7 +659,7 @@ def test_run_fails_call_saved_workflow_when_depth_limit_is_exceeded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_queue = _DummySessionQueue()
- runner, events = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
diff --git a/tests/app/services/test_workflow_graph_builder.py b/tests/app/services/test_workflow_graph_builder.py
new file mode 100644
index 00000000000..4b294d24b31
--- /dev/null
+++ b/tests/app/services/test_workflow_graph_builder.py
@@ -0,0 +1,115 @@
+from invokeai.app.services.shared.graph import Graph
+from invokeai.app.services.shared.workflow_graph_builder import build_graph_from_workflow
+
+
+def _build_workflow_node(
+ node_id: str,
+ invocation_type: str,
+ inputs: dict[str, object],
+ *,
+ is_intermediate: bool = False,
+ use_cache: bool = True,
+):
+ return {
+ "id": node_id,
+ "type": "invocation",
+ "position": {"x": 0, "y": 0},
+ "data": {
+ "id": node_id,
+ "type": invocation_type,
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "isOpen": True,
+ "isIntermediate": is_intermediate,
+ "useCache": use_cache,
+ "dynamicInputTemplates": {},
+ "inputs": {name: {"value": value} for name, value in inputs.items()},
+ },
+ }
+
+
+def _build_connector_node(node_id: str):
+ return {
+ "id": node_id,
+ "type": "connector",
+ "position": {"x": 0, "y": 0},
+ "data": {
+ "id": node_id,
+ "type": "connector",
+ "label": "Connector",
+ "isOpen": True,
+ },
+ }
+
+
+def _build_workflow(edges: list[dict], nodes: list[dict]):
+ return {
+ "name": "Child Workflow",
+ "author": "Tester",
+ "description": "",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "",
+ "notes": "",
+ "exposedFields": [],
+ "meta": {"version": "1.0.0", "category": "user"},
+ "nodes": nodes,
+ "edges": edges,
+ "form": None,
+ }
+
+
+def test_build_graph_from_workflow_converts_invocation_nodes():
+ workflow = _build_workflow(
+ nodes=[_build_workflow_node("add-1", "add", {"a": 1, "b": 2})],
+ edges=[],
+ )
+
+ graph = build_graph_from_workflow(workflow)
+
+ assert isinstance(graph, Graph)
+ assert set(graph.nodes.keys()) == {"add-1"}
+ assert graph.nodes["add-1"].get_type() == "add"
+ assert graph.nodes["add-1"].a == 1
+ assert graph.nodes["add-1"].b == 2
+
+
+def test_build_graph_from_workflow_flattens_connector_edges():
+ workflow = _build_workflow(
+ nodes=[
+ _build_workflow_node("add-1", "add", {"a": 1, "b": 2}),
+ _build_connector_node("connector-1"),
+ _build_workflow_node("add-2", "add", {"a": 999, "b": 3}),
+ ],
+ edges=[
+ {
+ "id": "edge-1",
+ "type": "default",
+ "source": "add-1",
+ "sourceHandle": "value",
+ "target": "connector-1",
+ "targetHandle": "in",
+ },
+ {
+ "id": "edge-2",
+ "type": "default",
+ "source": "connector-1",
+ "sourceHandle": "out",
+ "target": "add-2",
+ "targetHandle": "a",
+ },
+ ],
+ )
+
+ graph = build_graph_from_workflow(workflow)
+
+ assert len(graph.edges) == 1
+ edge = graph.edges[0]
+ assert edge.source.node_id == "add-1"
+ assert edge.source.field == "value"
+ assert edge.destination.node_id == "add-2"
+ assert edge.destination.field == "a"
+ assert graph.nodes["add-2"].a == 0
+ assert graph.nodes["add-2"].b == 3
From 1caf6ffad60d81ea0b644232fd6947c2377b8483 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 10:31:02 -0500
Subject: [PATCH 037/100] Update workflow call runtime documentation
---
docs/contributing/call_saved_workflow.md | 203 +++++++++++++----------
invokeai/app/services/shared/README.md | 12 +-
2 files changed, 125 insertions(+), 90 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index c48ed252baf..fe38f3a4ab7 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -2,17 +2,20 @@
## Goal
-`CallSavedWorkflowInvocation` should become an engine-native workflow call boundary, not a frontend-only dynamic node and not a compile-time graph inliner.
+`CallSavedWorkflowInvocation` should become an engine-native workflow call boundary, not a frontend-only dynamic node
+and not a compile-time graph inliner.
The long-term feature goal is:
- A parent workflow can call a saved workflow selected by ID.
- The call node redraws in the editor based on the selected workflow's exposed form fields.
- Parent values and inbound connections bind to those exposed fields as call arguments.
-- Execution suspends at the call node, runs the selected workflow as a dependent workflow execution, captures explicit return values, and then resumes the parent workflow.
+- Execution suspends at the call node, runs the selected workflow as a dependent workflow execution, captures explicit
+ return values, and then resumes the parent workflow.
- The architecture must work for Invoke frontend graphs and for externally submitted graphs that use the same node type.
-This document records the current state, the target architecture, and the execution contract needed to continue development later.
+This document records the current state, the target architecture, and the execution contract needed to continue
+development later.
## Implementation Priority
@@ -24,30 +27,67 @@ The work may still proceed incrementally, but each increment should satisfy all
- compatible with the long-term architecture described here
- non-breaking to existing code and existing workflow execution behavior
-Speed is not the primary goal for this phase. The primary goal is to move toward the durable design without introducing throwaway execution semantics that would need to be unwound later.
+Speed is not the primary goal for this phase. The primary goal is to move toward the durable design without introducing
+throwaway execution semantics that would need to be unwound later.
## Current State
-Implemented already:
+Implemented already in the branch:
- A real invocation exists: `call_saved_workflow`.
+- A real return node exists: `workflow_return`.
+- `workflow_return` accepts a `list[Any]` collection input and returns that collection through a dedicated output.
+- Only one `workflow_return` node is allowed per workflow, enforced in both frontend validation and Python validation.
- The frontend provides a saved-workflow picker using a reusable `SavedWorkflowField` UI type.
- The node redraws dynamically based on the selected saved workflow's exposed form fields.
- Dynamic field values persist with the parent workflow.
-- Compatible inbound edges are preserved when switching between workflows with matching exposed field identities and compatible types.
+- Compatible inbound edges are preserved when switching between workflows with matching exposed field identities and
+ compatible types.
- Incompatible or no-longer-exposed inbound edges are removed in the editor.
- Backend validation exists for `workflow_id` existence and access rights.
-Important limitation:
-
-- The backend invocation class only has a static `workflow_id` input.
-- Dynamic exposed fields currently exist only in frontend/editor state.
-- Fresh connections to dynamic handles fail at invoke time because backend graph validation checks destination fields against real Python model fields.
+Implemented runtime scaffolding:
+
+- `GraphExecutionState` now persists workflow-call runtime state:
+ - `workflow_call_stack`
+ - `waiting_workflow_call`
+ - `waiting_workflow_call_child_session`
+ - `max_workflow_call_depth`
+- Nested and recursive calls are represented by the stack, with a runtime depth cap of 4.
+- `GraphExecutionState.next()` returns no runnable node while the parent session is waiting on a child workflow call.
+- `GraphExecutionState.is_complete()` stays false while waiting.
+- `DefaultSessionRunner.run_node()` now treats `call_saved_workflow` as a call boundary instead of a normal executable
+ node.
+- On boundary entry, the runner:
+ - validates the selected workflow
+ - builds a workflow call frame
+ - converts the saved workflow JSON into a backend `Graph`
+ - creates a child `GraphExecutionState`
+ - attaches that child session to the waiting parent session
+- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
+
+Implemented conversion helper:
+
+- `workflow_graph_builder.py` converts saved workflow JSON into an executable backend `Graph`.
+- It currently supports the invocation-node subset needed for this feature.
+- It flattens connector nodes and omits explicit destination field values when a connection exists, matching frontend
+ graph-build semantics.
+
+What is still not implemented:
+
+- the child workflow is not executed yet
+- the child workflow is not persisted as its own queue item
+- parent-child queue/session identifiers are not yet formalized beyond the attached child `GraphExecutionState`
+- `workflow_return` is not yet captured and propagated back to the parent
+- the suspended parent `call_saved_workflow` node is not yet resumed/completed from child results
+- dynamic connected inputs are still not executable end-to-end because child argument injection and child execution have
+ not been wired yet
Conclusion:
-- More frontend work alone will not make the node executable.
-- The next phase must be Python-side runtime architecture.
+- the editor contract is largely in place
+- the parent-side runtime call boundary is in place
+- child execution and return propagation are the remaining major runtime steps
## Architectural Direction
@@ -91,8 +131,8 @@ Fallback source for older workflows:
- `workflow.exposedFields`
-Only fields exposed by the child workflow form are callable inputs.
-Internal child inputs that exist in the workflow graph but are not exposed by the form are not part of the public call interface.
+Only fields exposed by the child workflow form are callable inputs. Internal child inputs that exist in the workflow
+graph but are not exposed by the form are not part of the public call interface.
### 2. Input Arguments
@@ -105,7 +145,8 @@ Each dynamic input must have:
- a default value if defined by the child workflow
- a user-facing label and description when available
-Current fast-path identity is based on child `nodeId + fieldName`. That is acceptable short-term in the editor, but a longer-term stable interface ID would be better if child workflows are frequently duplicated or refactored.
+Current fast-path identity is based on child `nodeId + fieldName`. That is acceptable short-term in the editor, but a
+longer-term stable interface ID would be better if child workflows are frequently duplicated or refactored.
### 3. Input Binding At Runtime
@@ -148,11 +189,10 @@ Recommended model:
- when the workflow is run via `call_saved_workflow`, that collection becomes the return value of the call
- `call_saved_workflow` should expose that collection as its return value in the first runtime version
-Only one workflow return node may exist per workflow.
-That rule should be enforced in both the frontend editor and in Python validation/runtime code.
+Only one workflow return node may exist per workflow. That rule should be enforced in both the frontend editor and in
+Python validation/runtime code.
-Do not infer child outputs from arbitrary terminal nodes.
-That is too ambiguous and too brittle.
+Do not infer child outputs from arbitrary terminal nodes. That is too ambiguous and too brittle.
### 6. Error Propagation
@@ -182,12 +222,13 @@ Initial implementation should enforce:
- maximum workflow call depth is capped at 4 call frames
- the depth cap is enforced at runtime, based on the active call stack, not by static validation alone
-This allows legitimate recursive or conditionally terminating workflow structures while still preventing unbounded call growth.
+This allows legitimate recursive or conditionally terminating workflow structures while still preventing unbounded call
+growth.
## Where The Runtime Work Belongs
-The goal is to support externally submitted graphs, not only frontend-authored graphs.
-Therefore the authoritative execution logic must live in Python.
+The goal is to support externally submitted graphs, not only frontend-authored graphs. Therefore the authoritative
+execution logic must live in Python.
Recommended high-level design:
@@ -202,22 +243,23 @@ Relevant existing path:
- session queue stores session state
- runtime executes through `GraphExecutionState`
-The next phase should identify the best Python insertion point for:
+Current insertion points already used:
+
+- `DefaultSessionRunner.run_node()` detects `call_saved_workflow` and enters boundary state
+- `GraphExecutionState` stores the waiting/call-stack state and attached child session
-- detecting when the next executable node is `call_saved_workflow`
-- suspending parent execution
-- launching a dependent child execution
-- collecting child return values
-- resuming the parent graph
+Next runtime work still needed:
-At a minimum, expect changes in Python runtime/session code rather than only in queue submission code.
+- decide where the attached child session actually runs
+- persist and/or formalize parent-child identifiers beyond the in-memory attached child session
+- define how the child completion or failure is delivered back to the suspended parent
+- complete the call node from child results
## Suggested Runtime Components
### CallSavedWorkflowRuntime
-A dedicated runtime helper for this node type should be introduced.
-Responsibilities:
+A dedicated runtime helper for this node type should be introduced. Responsibilities:
- load and validate the selected child workflow record
- validate runtime access rights
@@ -229,8 +271,7 @@ Responsibilities:
### Workflow Return Node
-A dedicated child-workflow return node should be introduced.
-Responsibilities:
+A dedicated child-workflow return node should be introduced. Responsibilities:
- define the return interface of the called workflow
- accept a `list[Any]` collection input representing the workflow result
@@ -252,23 +293,18 @@ Session/runtime state will likely need to record:
### Workflow Return Value Flow
-The workflow return value should not be persisted back into the saved workflow record and should not be derived from frontend state.
+The workflow return value should not be persisted back into the saved workflow record and should not be derived from
+frontend state.
The intended runtime flow is:
1. The child workflow computes the `workflow_return` node's collection input like any other node input.
-2. When the child reaches `workflow_return`, runtime captures the resolved collection value as the child workflow result.
-3. That result is stored in child execution state, or equivalent parent-child call-frame state, until the child finishes.
-4. When the child finishes successfully, the captured collection is passed back to the suspended parent call site.
-5. `call_saved_workflow` completes using that collection as its output value.
-6. The parent workflow resumes execution.
-
-Consequences of this model:
-
-- `workflow_return` is a normal invocation node in the child workflow
-- only one workflow return result may exist, because only one return node is allowed per workflow
-- the child result should live in runtime/session state, not in workflow persistence
-- return propagation should be explicit and deterministic
+1. When the child reaches `workflow_return`, runtime captures the resolved collection value as the child workflow
+ result.
+1. The child workflow result is stored in child execution state.
+1. That result is handed back to the suspended parent call frame.
+1. The parent `call_saved_workflow` node is completed with that returned collection.
+1. The parent graph resumes.
## Frontend Responsibilities In The Long-Term Design
@@ -278,56 +314,47 @@ The frontend remains responsible for editor-time behavior:
- redrawing dynamic inputs based on the child workflow callable interface
- persisting those dynamic fields and their values
- preserving compatible inbound edges when workflow selection changes
-- removing no-longer-valid inbound edges when the callable interface changes
-- eventually redrawing outputs if and when explicit workflow returns are added
-
-The frontend should not be the authoritative implementation of execution semantics.
-
-## Questions To Resolve Before Coding The Runtime
+- clearing incompatible edges and invalid selections in a predictable way
-1. Where exactly does parent execution pause and child execution resume in the current runtime stack?
-2. What is the narrowest first implementation of parent-child session state?
-3. The first runtime version should use the explicit workflow return node with a single collection-valued return, rather than inputs-only or ad hoc fixed outputs.
-4. Should child execution inherit all parent execution context, or only selected parts?
-5. What cancellation semantics apply if the parent session is cancelled while a child workflow is running?
-6. What metadata should be stored on queue items or sessions to represent call relationships and the captured child return value?
-7. Do dynamic input identities need a more stable external interface ID before runtime work begins?
+Potential future optimization:
-## Recommended Next Steps
+- add a backend endpoint that returns a normalized callable workflow interface
+- this would let the frontend avoid re-parsing full saved workflow payloads to redraw the node
+- it would also give the frontend a backend-authoritative interface hash for drift detection
-1. Design the explicit workflow return mechanism.
-2. Trace the Python runtime path needed to suspend and resume execution around a call node.
-3. Define a minimal parent-child session relationship model.
-4. Prototype runtime input passing for `call_saved_workflow` without nested calls.
-5. Add the workflow return node with frontend and Python enforcement that only one return node may exist per workflow.
-6. Add runtime call-stack tracking and maximum-depth enforcement.
-7. Add end-to-end tests for successful child call execution, missing child workflow, unauthorized child workflow, duplicate return nodes, maximum-depth failures, and child failure propagation.
+## Tests Needed Going Forward
-## Minimum Test Matrix For The Next Phase
+Already covered:
-Positive tests:
+- workflow-call stack and waiting state on `GraphExecutionState`
+- depth-limit enforcement
+- waiting blocks scheduling
+- parent sessions are not completed while waiting
+- runner boundary entry for `call_saved_workflow`
+- validation failures and depth-limit failures still follow normal node-error behavior
+- child workflow JSON conversion to backend `Graph`
+- child graph build failure does not leave the parent in a partial waiting state
+- child `GraphExecutionState` is attached to the waiting parent session
-- parent workflow calls child workflow successfully with literal arguments
-- parent workflow calls child workflow successfully with connected arguments
-- child workflow returns its explicit `list[Any]` collection value to the parent
-- runtime enforces child defaults when parent does not override them
+Still needed in later increments:
-Negative tests:
+- actual child execution lifecycle
+- parent-child resume behavior
+- child failure propagation into parent failure
+- `workflow_return` capture and propagation
+- nested runtime execution beyond a single attached child state
+- eventual queue/session persistence rules if child executions become first-class queue items
-- missing selected workflow fails cleanly
-- unauthorized selected workflow fails cleanly
-- child workflow missing return definition fails cleanly if returns are required
-- duplicate workflow return nodes are rejected
-- recursive calls that exceed the maximum depth are rejected
-- nested calls that exceed the maximum depth are rejected
-- child workflow failure propagates to the parent
-- cancellation while child is running produces a deterministic failure state
+## Recommended Immediate Next Step
-## Summary
+The next incremental step should be:
-The project is past the frontend proof-of-concept stage.
+- decide and implement where the attached child `GraphExecutionState` actually runs
+- keep that step test-first
+- still defer `workflow_return` propagation until the child execution path exists and is stable
-What remains is a real engine-level workflow call mechanism.
-The architecture most likely to be kept is a runtime call boundary with dependent child execution and explicit returns, not compile-time graph inlining.
+The current branch is at the point where:
-That should be the basis for the next phase of work.
+- parent call-boundary state exists
+- child execution state can be created from the selected saved workflow
+- but no child execution or parent resume path exists yet
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 113b7a41e54..2e31f148601 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -102,6 +102,11 @@ mutation helpers. Those helpers reject changes once the affected nodes have alre
- `prepared_source_mapping: dict[str, str]` - exec id -> source id.
- `source_prepared_mapping: dict[str, set[str]]` - source id -> exec ids.
- `indegree: dict[str, int]` - unmet inputs per exec node.
+- Workflow-call runtime state:
+ - `workflow_call_stack` - active parent call frames.
+ - `waiting_workflow_call` - the call frame currently suspending this execution state, if any.
+ - `waiting_workflow_call_child_session` - attached child execution state for the waiting workflow call, if any.
+ - `max_workflow_call_depth` - runtime guardrail for nested or recursive workflow calls.
- Prepared exec metadata caches:
- source node id
- iteration path
@@ -112,7 +117,8 @@ mutation helpers. Those helpers reject changes once the affected nodes have alre
### 4.2 Core methods
- `next()` Returns the next ready exec node. If none are ready, it asks the materializer to expand more source nodes and
- then retries. Before returning a node, the runtime helper deep-copies inbound values into the node fields.
+ then retries. If the execution state is paused on a workflow call boundary, it returns `None` without scheduling more
+ work. Before returning a node, the runtime helper deep-copies inbound values into the node fields.
- `complete(node_id, output)` Records the result, marks the exec node executed, marks the source node executed once all
of its prepared exec copies are done, then decrements downstream indegrees and enqueues newly ready nodes.
@@ -208,7 +214,7 @@ This behavior is implemented in the runtime scheduler, not in the invocation bod
- Execute node externally -> `output`.
- `state.complete(node.id, output)` -> updates indegrees, `If` state, and ready queues.
-1. Finish when `next()` returns `None`.
+1. Finish when `next()` returns `None` and the execution state is not paused waiting on a workflow call boundary.
In normal execution, all runtime expansion occurs in `execution_graph` with traceability back to source nodes.
@@ -229,6 +235,8 @@ In normal execution, all runtime expansion occurs in `execution_graph` with trac
complexity.
- **Dynamic behaviors** (future): can be added in `GraphExecutionState` by creating exec nodes and edges at `complete()`
time, as long as the DAG invariant holds.
+- **Workflow call boundaries**: `GraphExecutionState` can suspend a parent execution state on a workflow call, attach a
+ child execution state, and later resume the parent without mutating the source graph.
## 8) Error Model (selected)
From 4e5c0450cb8a5db8ea72334b766bab01dcaa3259 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 13:49:10 -0500
Subject: [PATCH 038/100] Localize saved workflow node UI strings
---
invokeai/frontend/web/public/locales/en.json | 5 +++
.../Invocation/CallSavedWorkflowNode.tsx | 20 ++++++++++--
.../SavedWorkflowFieldInputComponent.tsx | 31 ++++++++++++++-----
.../inputs/savedWorkflowFieldUtils.test.ts | 8 +----
.../fields/inputs/savedWorkflowFieldUtils.ts | 14 ++-------
5 files changed, 48 insertions(+), 30 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index fd6154760e3..e3add16d1ec 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1396,6 +1396,11 @@
"loadWorkflow": "Load Workflow",
"noWorkflows": "No Workflows",
"noMatchingWorkflows": "No Matching Workflows",
+ "savedWorkflowChoose": "Choose a workflow",
+ "savedWorkflowDefaultBadge": "Default",
+ "savedWorkflowMissing": "Missing or inaccessible workflow",
+ "savedWorkflowSelectExposedFields": "Select a workflow with exposed form fields",
+ "savedWorkflowUpdating": "Updating...",
"noWorkflow": "No Workflow",
"noWorkflowToSave": "No workflow to save",
"unableToUpdateNode": "Node update failed: node {{node}} of type {{type}} (may require deleting and recreating)",
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
index 55a58e72a6b..7840548cf1b 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
@@ -15,6 +15,7 @@ import { $templates, callSavedWorkflowDynamicFieldsChanged } from 'features/node
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { SavedWorkflowFieldInputInstance } from 'features/nodes/types/field';
import { memo, useEffect, useMemo, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
import { getSavedWorkflowDynamicEdgeIdsToRemove, getSavedWorkflowDynamicFields } from './callSavedWorkflowFormUtils';
@@ -45,6 +46,7 @@ type Props = {
const CallSavedWorkflowNode = ({ nodeId, isOpen }: Props) => {
const withFooter = useWithFooter();
+ const { t } = useTranslation();
const workflowIdField = useInputFieldInstance('workflow_id');
const templates = useStore($templates);
const dispatch = useAppDispatch();
@@ -95,7 +97,11 @@ const CallSavedWorkflowNode = ({ nodeId, isOpen }: Props) => {
-
+
{withFooter && }
@@ -108,11 +114,19 @@ const CallSavedWorkflowNode = ({ nodeId, isOpen }: Props) => {
export default memo(CallSavedWorkflowNode);
const DynamicFieldsSection = memo(
- ({ nodeId, fields }: { nodeId: string; fields: ReturnType }) => {
+ ({
+ nodeId,
+ fields,
+ emptyMessage,
+ }: {
+ nodeId: string;
+ fields: ReturnType;
+ emptyMessage: string;
+ }) => {
if (fields.length === 0) {
return (
- Select a workflow with exposed form fields
+ {emptyMessage}
);
}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
index 1dcc978f643..8cf3714c0dc 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
@@ -11,10 +11,9 @@ import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/wo
import {
buildSavedWorkflowOptions,
- EMPTY_SELECTION_LABEL,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
- getSavedWorkflowSelectionStatusLabel,
+ MISSING_WORKFLOW_OPTION_VALUE,
} from './savedWorkflowFieldUtils';
import type { FieldComponentProps } from './types';
@@ -47,8 +46,22 @@ const SavedWorkflowFieldInputComponent = (
const options = useMemo(() => buildSavedWorkflowOptions(items), [items]);
const selectionState = useMemo(() => getSavedWorkflowSelectionState(items, field.value), [field.value, items]);
- const value = useMemo(() => getSavedWorkflowSelectionOption(selectionState), [selectionState]);
- const statusLabel = useMemo(() => getSavedWorkflowSelectionStatusLabel(selectionState), [selectionState]);
+ const value = useMemo(() => {
+ const option = getSavedWorkflowSelectionOption(selectionState);
+ if (option?.value === MISSING_WORKFLOW_OPTION_VALUE) {
+ return {
+ ...option,
+ label: t('nodes.savedWorkflowMissing'),
+ };
+ }
+ return option;
+ }, [selectionState, t]);
+ const statusLabel = useMemo(() => {
+ if (selectionState.status === 'selected') {
+ return null;
+ }
+ return selectionState.status === 'missing' ? t('nodes.savedWorkflowMissing') : t('nodes.savedWorkflowChoose');
+ }, [selectionState.status, t]);
const onChange = useCallback(
(v) => {
@@ -81,17 +94,19 @@ const SavedWorkflowFieldInputComponent = (
{selectionState.workflow.name}
- {selectionState.workflow.category === 'default' && Default}
+ {selectionState.workflow.category === 'default' && (
+ {t('nodes.savedWorkflowDefaultBadge')}
+ )}
{selectionState.workflow.is_public && selectionState.workflow.category !== 'default' && (
- Shared
+ {t('workflows.shared')}
)}
) : (
- {statusLabel ?? EMPTY_SELECTION_LABEL}
+ {statusLabel ?? t('nodes.savedWorkflowChoose')}
)}
{isFetching && (
- Updating...
+ {t('nodes.savedWorkflowUpdating')}
)}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
index e2bb33fc0cc..f7c7c3be0df 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
@@ -3,11 +3,8 @@ import { describe, expect, it } from 'vitest';
import {
buildSavedWorkflowOptions,
- EMPTY_SELECTION_LABEL,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
- getSavedWorkflowSelectionStatusLabel,
- MISSING_SELECTION_LABEL,
MISSING_WORKFLOW_OPTION_VALUE,
} from './savedWorkflowFieldUtils';
@@ -52,7 +49,6 @@ describe('savedWorkflowFieldUtils', () => {
const selectionState = getSavedWorkflowSelectionState(workflows, '');
expect(selectionState).toEqual({ status: 'unselected' });
expect(getSavedWorkflowSelectionOption(selectionState)).toBeNull();
- expect(getSavedWorkflowSelectionStatusLabel(selectionState)).toBe(EMPTY_SELECTION_LABEL);
});
it('returns a selected state for a valid workflow id', () => {
@@ -62,16 +58,14 @@ describe('savedWorkflowFieldUtils', () => {
label: 'Beta Workflow',
value: 'workflow-b',
});
- expect(getSavedWorkflowSelectionStatusLabel(selectionState)).toBeNull();
});
it('returns a missing state for a stale or inaccessible workflow id', () => {
const selectionState = getSavedWorkflowSelectionState(workflows, 'missing-workflow');
expect(selectionState).toEqual({ status: 'missing', workflowId: 'missing-workflow' });
expect(getSavedWorkflowSelectionOption(selectionState)).toEqual({
- label: MISSING_SELECTION_LABEL,
+ label: MISSING_WORKFLOW_OPTION_VALUE,
value: MISSING_WORKFLOW_OPTION_VALUE,
});
- expect(getSavedWorkflowSelectionStatusLabel(selectionState)).toBe(MISSING_SELECTION_LABEL);
});
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index d98b41e8fa0..bea62f0f1f1 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -2,10 +2,8 @@ import type { ComboboxOption } from '@invoke-ai/ui-library';
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
-export const MISSING_SELECTION_LABEL = 'Missing or inaccessible workflow';
-export const EMPTY_SELECTION_LABEL = 'Choose a workflow';
-type SavedWorkflowSelectionState =
+export type SavedWorkflowSelectionState =
| { status: 'unselected' }
| { status: 'selected'; workflow: WorkflowRecordListItemWithThumbnailDTO }
| { status: 'missing'; workflowId: string };
@@ -46,15 +44,7 @@ export const getSavedWorkflowSelectionOption = (selectionState: SavedWorkflowSel
}
return {
- label: MISSING_SELECTION_LABEL,
+ label: MISSING_WORKFLOW_OPTION_VALUE,
value: MISSING_WORKFLOW_OPTION_VALUE,
};
};
-
-export const getSavedWorkflowSelectionStatusLabel = (selectionState: SavedWorkflowSelectionState): string | null => {
- if (selectionState.status === 'selected') {
- return null;
- }
-
- return selectionState.status === 'missing' ? MISSING_SELECTION_LABEL : EMPTY_SELECTION_LABEL;
-};
From 39f6c17c1e06fd36ed09dcc609822f6ac90478e6 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 13:49:21 -0500
Subject: [PATCH 039/100] Execute saved workflow children inline
---
docs/contributing/call_saved_workflow.md | 53 +++-
.../app/invocations/call_saved_workflow.py | 25 ++
.../session_processor_default.py | 63 +++-
invokeai/app/services/shared/graph.py | 23 +-
.../services/shared/workflow_graph_builder.py | 133 +++++++++
.../nodes/util/graph/buildNodesGraph.test.ts | 56 +++-
.../nodes/util/graph/buildNodesGraph.ts | 23 ++
.../invocations/test_call_saved_workflows.py | 3 +
.../test_session_processor_shutdown.py | 269 ++++++++++++++++--
.../services/test_workflow_graph_builder.py | 17 +-
10 files changed, 623 insertions(+), 42 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index fe38f3a4ab7..7f415a39ce7 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -62,9 +62,16 @@ Implemented runtime scaffolding:
- validates the selected workflow
- builds a workflow call frame
- converts the saved workflow JSON into a backend `Graph`
+ - validates and applies parent call arguments to the child graph
- creates a child `GraphExecutionState`
- attaches that child session to the waiting parent session
+ - runs the child session inline in the runner
+ - clears the waiting state and completes the parent `call_saved_workflow` node with the current stub output
- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
+- Dynamic call arguments now execute end-to-end in the current runner path:
+ - literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node
+ - connected dynamic values are accepted as special call-boundary edges and are resolved from parent results at runtime
+ - both are validated against the child workflow's exposed form interface before being applied to the child graph
Implemented conversion helper:
@@ -75,19 +82,20 @@ Implemented conversion helper:
What is still not implemented:
-- the child workflow is not executed yet
+- the child workflow is executed inline by the parent runner, not yet as its own queue item or scheduler-visible unit
- the child workflow is not persisted as its own queue item
- parent-child queue/session identifiers are not yet formalized beyond the attached child `GraphExecutionState`
- `workflow_return` is not yet captured and propagated back to the parent
-- the suspended parent `call_saved_workflow` node is not yet resumed/completed from child results
-- dynamic connected inputs are still not executable end-to-end because child argument injection and child execution have
- not been wired yet
+- the parent call node still completes with a temporary stub output rather than child return data
+- saved workflows containing batch-special nodes such as `image_batch` are not supported by the current child graph
+ reconstruction path and must fail with a clear domain error rather than a low-level constructor error
Conclusion:
- the editor contract is largely in place
- the parent-side runtime call boundary is in place
-- child execution and return propagation are the remaining major runtime steps
+- child execution and argument forwarding are now in place through an inline runner path
+- return propagation and first-class child execution semantics are the remaining major runtime steps
## Architectural Direction
@@ -163,6 +171,9 @@ Argument values may come from:
- parent literal field values
- resolved inbound connections into the call node's dynamic inputs
+For batch-aware child workflows, the parent call boundary should still pass normal exposed form inputs. Batching should
+emerge from the child workflow's own internal batch nodes or generators, not from a separate caller-side batch protocol.
+
### 4. Child Workflow Execution
The child workflow runs as its own dependent execution context, not as an inlined copy of the parent graph.
@@ -176,6 +187,16 @@ Desired semantics:
This implies the queue/session/runtime layer needs an explicit parent-child execution relationship.
+Current limitation:
+
+- the temporary `workflow_graph_builder.py` path can only reconstruct the subset of child workflows that can be parsed
+ as ordinary backend invocation graphs
+- batch-special nodes from `invokeai.app.invocations.batch` are not yet supported in called workflows
+- until the child execution path is closer to normal session execution, batch-special nodes should fail early with a
+ clear unsupported-feature error
+- the current child execution path runs inline inside `DefaultSessionRunner`, so child execution is not yet a
+ first-class queue/session entity
+
### 5. Return Values
Return values should be explicit.
@@ -247,13 +268,18 @@ Current insertion points already used:
- `DefaultSessionRunner.run_node()` detects `call_saved_workflow` and enters boundary state
- `GraphExecutionState` stores the waiting/call-stack state and attached child session
+- `DefaultSessionRunner.run_node()` currently executes the attached child session inline before completing the parent
+ call node with the stub output
Next runtime work still needed:
-- decide where the attached child session actually runs
+- decide whether the inline child-session execution should remain temporarily or move to a more explicit parent-child
+ runtime boundary
- persist and/or formalize parent-child identifiers beyond the in-memory attached child session
- define how the child completion or failure is delivered back to the suspended parent
- complete the call node from child results
+- replace the current unsupported batch-special-node limitation by routing child execution through machinery that can
+ honor ordinary Invoke batch semantics
## Suggested Runtime Components
@@ -335,26 +361,31 @@ Already covered:
- child workflow JSON conversion to backend `Graph`
- child graph build failure does not leave the parent in a partial waiting state
- child `GraphExecutionState` is attached to the waiting parent session
+- inline child execution completes the parent queue item instead of leaving it stuck in `in_progress`
+- literal and connected dynamic call arguments are applied to the child graph at runtime
+- non-exposed dynamic call arguments are rejected at runtime
Still needed in later increments:
-- actual child execution lifecycle
-- parent-child resume behavior
+- parent-child resume behavior once child execution is no longer an inline runner detail
- child failure propagation into parent failure
- `workflow_return` capture and propagation
- nested runtime execution beyond a single attached child state
- eventual queue/session persistence rules if child executions become first-class queue items
+- eventual support for child workflows that contain batch-special nodes, once child execution is run through the proper
+ batch/session path
## Recommended Immediate Next Step
The next incremental step should be:
-- decide and implement where the attached child `GraphExecutionState` actually runs
+- replace the stub parent completion path with explicit `workflow_return` capture and propagation
- keep that step test-first
-- still defer `workflow_return` propagation until the child execution path exists and is stable
+- preserve the current bounded nested-call runtime state while making return/resume behavior explicit
The current branch is at the point where:
- parent call-boundary state exists
- child execution state can be created from the selected saved workflow
-- but no child execution or parent resume path exists yet
+- child execution and argument forwarding work through the inline runner path
+- but explicit return propagation and long-term child-session execution semantics are still missing
diff --git a/invokeai/app/invocations/call_saved_workflow.py b/invokeai/app/invocations/call_saved_workflow.py
index fad7dd156a9..97302216745 100644
--- a/invokeai/app/invocations/call_saved_workflow.py
+++ b/invokeai/app/invocations/call_saved_workflow.py
@@ -1,9 +1,29 @@
+from typing import Any
+
from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
from invokeai.app.invocations.fields import InputField, UIType
from invokeai.app.invocations.primitives import IntegerOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory, WorkflowNotFoundError
+CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX = "saved_workflow_input::"
+
+
+def is_call_saved_workflow_dynamic_input(field_name: str) -> bool:
+ return field_name.startswith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX)
+
+
+def parse_call_saved_workflow_dynamic_input(field_name: str) -> tuple[str, str]:
+ if not is_call_saved_workflow_dynamic_input(field_name):
+ raise ValueError(f"'{field_name}' is not a call_saved_workflow dynamic input field")
+
+ raw_identifier = field_name.removeprefix(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX)
+ node_id, separator, input_field_name = raw_identifier.rpartition("::")
+ if not separator or not node_id or not input_field_name:
+ raise ValueError(f"Invalid call_saved_workflow dynamic input field '{field_name}'")
+
+ return node_id, input_field_name
+
@invocation(
"call_saved_workflow",
@@ -22,6 +42,11 @@ class CallSavedWorkflowInvocation(BaseInvocation):
description="The selected saved workflow ID, managed by the workflow editor UI.",
ui_type=UIType.SavedWorkflow,
)
+ workflow_inputs: dict[str, Any] = InputField(
+ default_factory=dict,
+ description="Literal values for the selected workflow's exposed inputs, managed by the workflow editor UI.",
+ ui_hidden=True,
+ )
def validate_selected_workflow(self, context: InvocationContext):
if not self.workflow_id:
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index edf4865b70b..b2c983f4bcf 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -3,10 +3,14 @@
from contextlib import suppress
from threading import BoundedSemaphore, Thread
from threading import Event as ThreadEvent
-from typing import Optional
+from typing import Any, Optional
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput
-from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
+from invokeai.app.invocations.call_saved_workflow import (
+ CallSavedWorkflowInvocation,
+ is_call_saved_workflow_dynamic_input,
+)
+from invokeai.app.invocations.primitives import IntegerOutput
from invokeai.app.services.events.events_common import (
BatchEnqueuedEvent,
FastAPIEvent,
@@ -31,7 +35,10 @@
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem, SessionQueueItemNotFoundError
from invokeai.app.services.shared.graph import NodeInputError
from invokeai.app.services.shared.invocation_context import InvocationContextData, build_invocation_context
-from invokeai.app.services.shared.workflow_graph_builder import build_graph_from_workflow
+from invokeai.app.services.shared.workflow_graph_builder import (
+ apply_workflow_inputs_to_graph,
+ build_graph_from_workflow,
+)
from invokeai.app.util.profiler import Profiler
@@ -71,11 +78,7 @@ def _is_canceled(self) -> bool:
denoising to check if the session has been canceled."""
return self._cancel_event.is_set()
- def run(self, queue_item: SessionQueueItem):
- # Exceptions raised outside `run_node` are handled by the processor. There is no need to catch them here.
-
- self._on_before_run_session(queue_item=queue_item)
-
+ def _run_session_loop(self, queue_item: SessionQueueItem) -> None:
# Loop over invocations until the session is complete or canceled
while True:
try:
@@ -109,6 +112,34 @@ def run(self, queue_item: SessionQueueItem):
):
break
+ def _collect_call_saved_workflow_inputs(
+ self, invocation: CallSavedWorkflowInvocation, queue_item: SessionQueueItem
+ ) -> dict[str, Any]:
+ workflow_inputs = dict(invocation.workflow_inputs)
+ for edge in queue_item.session.execution_graph._get_input_edges(invocation.id):
+ if not is_call_saved_workflow_dynamic_input(edge.destination.field):
+ continue
+ if edge.source.node_id not in queue_item.session.results:
+ continue
+ workflow_inputs[edge.destination.field] = getattr(
+ queue_item.session.results[edge.source.node_id], edge.source.field
+ )
+ return workflow_inputs
+
+ @staticmethod
+ def _build_child_queue_item(queue_item: SessionQueueItem, child_session) -> SessionQueueItem:
+ if hasattr(queue_item, "model_copy"):
+ return queue_item.model_copy(update={"session": child_session, "session_id": child_session.id})
+
+ child_queue_item = type(queue_item).__new__(type(queue_item))
+ child_queue_item.__dict__ = {**queue_item.__dict__, "session": child_session, "session_id": child_session.id}
+ return child_queue_item
+
+ def run(self, queue_item: SessionQueueItem):
+ # Exceptions raised outside `run_node` are handled by the processor. There is no need to catch them here.
+
+ self._on_before_run_session(queue_item=queue_item)
+ self._run_session_loop(queue_item)
self._on_after_run_session(queue_item=queue_item)
def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem):
@@ -132,9 +163,25 @@ def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem):
workflow_record = invocation.validate_selected_workflow(context)
call_frame = queue_item.session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
child_graph = build_graph_from_workflow(workflow_record.workflow.model_dump())
+ apply_workflow_inputs_to_graph(
+ child_graph,
+ workflow_record.workflow.model_dump(),
+ self._collect_call_saved_workflow_inputs(invocation, queue_item),
+ )
child_session = queue_item.session.create_child_workflow_execution_state(child_graph, call_frame)
queue_item.session.begin_waiting_on_workflow_call(call_frame)
queue_item.session.attach_waiting_workflow_call_child_session(child_session)
+ child_queue_item = self._build_child_queue_item(queue_item, child_session)
+ self._run_session_loop(child_queue_item)
+ if child_session.has_error():
+ raise ValueError(
+ f"The selected saved workflow '{invocation.workflow_id}' failed during child execution."
+ )
+
+ queue_item.session.end_waiting_on_workflow_call()
+ output = IntegerOutput(value=0)
+ queue_item.session.complete(invocation.id, output)
+ self._on_after_run_node(invocation, queue_item, output)
return
# Invoke the node
diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py
index 81283119a67..85dc2c2744c 100644
--- a/invokeai/app/services/shared/graph.py
+++ b/invokeai/app/services/shared/graph.py
@@ -29,6 +29,10 @@
invocation,
invocation_output,
)
+from invokeai.app.invocations.call_saved_workflow import (
+ CallSavedWorkflowInvocation,
+ is_call_saved_workflow_dynamic_input,
+)
from invokeai.app.invocations.fields import Input, InputField, OutputField, UIType
from invokeai.app.invocations.logic import IfInvocation
from invokeai.app.services.shared.invocation_context import InvocationContext
@@ -772,6 +776,10 @@ def _set_node_inputs(
for edge in input_edges:
if allowed_fields is not None and edge.destination.field not in allowed_fields:
continue
+ if isinstance(node, CallSavedWorkflowInvocation) and is_call_saved_workflow_dynamic_input(
+ edge.destination.field
+ ):
+ continue
setattr(node, edge.destination.field, self._get_copied_result_value(edge))
def _prepare_collect_inputs(self, node: "CollectInvocation", input_edges: list[Edge]) -> None:
@@ -1188,6 +1196,10 @@ def _validate_edge_nodes_and_fields(self) -> None:
)
if edge.destination.field not in type(destination_node).model_fields:
+ if isinstance(destination_node, CallSavedWorkflowInvocation) and is_call_saved_workflow_dynamic_input(
+ edge.destination.field
+ ):
+ continue
raise NodeFieldNotFoundError(
f"Edge destination field {edge.destination.field} does not exist in node {edge.destination.node_id}"
)
@@ -1199,10 +1211,15 @@ def _validate_graph_is_acyclic(self) -> None:
def _validate_edge_type_compatibility(self) -> None:
for edge in self.edges:
+ destination_node = self.get_node(edge.destination.node_id)
+ if isinstance(destination_node, CallSavedWorkflowInvocation) and is_call_saved_workflow_dynamic_input(
+ edge.destination.field
+ ):
+ continue
if not are_connections_compatible(
self.get_node(edge.source.node_id),
edge.source.field,
- self.get_node(edge.destination.node_id),
+ destination_node,
edge.destination.field,
):
raise InvalidEdgeError(f"Edge source and target types do not match ({edge})")
@@ -1292,6 +1309,10 @@ def _validate_edge_would_not_create_cycle(self, edge: Edge) -> None:
def _validate_edge_field_compatibility(
self, edge: Edge, source_node: BaseInvocation, destination_node: BaseInvocation
) -> None:
+ if isinstance(destination_node, CallSavedWorkflowInvocation) and is_call_saved_workflow_dynamic_input(
+ edge.destination.field
+ ):
+ return
if not are_connections_compatible(source_node, edge.source.field, destination_node, edge.destination.field):
raise InvalidEdgeError(f"Field types are incompatible ({edge})")
diff --git a/invokeai/app/services/shared/workflow_graph_builder.py b/invokeai/app/services/shared/workflow_graph_builder.py
index f6aced09bef..94431d4f494 100644
--- a/invokeai/app/services/shared/workflow_graph_builder.py
+++ b/invokeai/app/services/shared/workflow_graph_builder.py
@@ -1,12 +1,25 @@
from collections.abc import Mapping, Sequence
from typing import Any
+from invokeai.app.invocations.baseinvocation import Classification, InvocationRegistry
+from invokeai.app.invocations.call_saved_workflow import (
+ CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX,
+ parse_call_saved_workflow_dynamic_input,
+)
from invokeai.app.services.shared.graph import Edge, EdgeConnection, Graph
CONNECTOR_INPUT_HANDLE = "in"
CONNECTOR_OUTPUT_HANDLE = "out"
+class UnsupportedWorkflowNodeError(ValueError):
+ pass
+
+
+class InvalidWorkflowInputError(ValueError):
+ pass
+
+
def _is_mapping(value: Any) -> bool:
return isinstance(value, Mapping)
@@ -19,6 +32,124 @@ def _is_connector_node(node: Any) -> bool:
return _is_mapping(node) and node.get("type") == "connector"
+def _build_dynamic_input_name(node_id: str, field_name: str) -> str:
+ return f"{CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX}{node_id}::{field_name}"
+
+
+def _get_form_elements(workflow: Mapping[str, Any]) -> tuple[Mapping[str, Any], str | None]:
+ form = workflow.get("form")
+ if not _is_mapping(form):
+ return {}, None
+
+ elements = form.get("elements")
+ root_element_id = form.get("rootElementId")
+ if not _is_mapping(elements) or not isinstance(root_element_id, str):
+ return {}, None
+
+ return elements, root_element_id
+
+
+def _collect_exposed_inputs_from_form(workflow: Mapping[str, Any]) -> set[str]:
+ elements, root_element_id = _get_form_elements(workflow)
+ if not elements or root_element_id is None:
+ return set()
+
+ exposed_inputs: set[str] = set()
+ stack = [root_element_id]
+ visited: set[str] = set()
+
+ while stack:
+ element_id = stack.pop()
+ if element_id in visited:
+ continue
+ visited.add(element_id)
+
+ element = elements.get(element_id)
+ if not _is_mapping(element):
+ continue
+
+ if element.get("type") == "node-field":
+ data = element.get("data")
+ if _is_mapping(data):
+ field_identifier = data.get("fieldIdentifier")
+ if _is_mapping(field_identifier):
+ node_id = field_identifier.get("nodeId")
+ field_name = field_identifier.get("fieldName")
+ if isinstance(node_id, str) and isinstance(field_name, str):
+ exposed_inputs.add(_build_dynamic_input_name(node_id, field_name))
+
+ data = element.get("data")
+ if _is_mapping(data):
+ children = data.get("children")
+ if isinstance(children, Sequence):
+ for child_id in reversed(children):
+ if isinstance(child_id, str):
+ stack.append(child_id)
+
+ return exposed_inputs
+
+
+def get_exposed_workflow_input_names(workflow: Mapping[str, Any]) -> set[str]:
+ exposed_inputs = _collect_exposed_inputs_from_form(workflow)
+ if exposed_inputs:
+ return exposed_inputs
+
+ workflow_exposed_fields = workflow.get("exposedFields", [])
+ if not isinstance(workflow_exposed_fields, Sequence):
+ return set()
+
+ fallback_inputs: set[str] = set()
+ for field in workflow_exposed_fields:
+ if not _is_mapping(field):
+ continue
+ node_id = field.get("nodeId")
+ field_name = field.get("fieldName")
+ if isinstance(node_id, str) and isinstance(field_name, str):
+ fallback_inputs.add(_build_dynamic_input_name(node_id, field_name))
+
+ return fallback_inputs
+
+
+def apply_workflow_inputs_to_graph(graph: Graph, workflow: Mapping[str, Any], workflow_inputs: Mapping[str, Any]) -> None:
+ if not workflow_inputs:
+ return
+
+ allowed_inputs = get_exposed_workflow_input_names(workflow)
+ for input_name, value in workflow_inputs.items():
+ if input_name not in allowed_inputs:
+ raise InvalidWorkflowInputError(
+ f"call_saved_workflow input '{input_name}' is not exposed by the selected workflow"
+ )
+
+ node_id, field_name = parse_call_saved_workflow_dynamic_input(input_name)
+ node = graph.nodes.get(node_id)
+ if node is None:
+ raise InvalidWorkflowInputError(
+ f"call_saved_workflow input '{input_name}' targets missing child workflow node '{node_id}'"
+ )
+ if field_name not in type(node).model_fields:
+ raise InvalidWorkflowInputError(
+ f"call_saved_workflow input '{input_name}' targets missing child workflow field '{field_name}'"
+ )
+
+ setattr(node, field_name, value)
+
+
+def _raise_if_unsupported_invocation_type(node_type: str, node_id: str) -> None:
+ invocation_class = InvocationRegistry.get_invocation_for_type(node_type)
+ if invocation_class is None:
+ return
+
+ if (
+ invocation_class.UIConfig.category == "batch"
+ and invocation_class.UIConfig.classification == Classification.Special
+ ):
+ raise UnsupportedWorkflowNodeError(
+ f"call_saved_workflow does not yet support batch-special child workflow nodes such as "
+ f"'{node_type}' (node '{node_id}')"
+ )
+
+
def _get_default_edges(workflow_edges: Sequence[Any]) -> list[Mapping[str, Any]]:
return [edge for edge in workflow_edges if _is_mapping(edge) and edge.get("type") == "default"]
@@ -90,6 +221,8 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph:
if not isinstance(node_id, str) or not isinstance(node_type, str):
continue
+ _raise_if_unsupported_invocation_type(node_type, node_id)
+
graph_node: dict[str, Any] = {
"id": node_id,
"type": node_type,
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
index f0537ca0ea0..b6cbdcb2525 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
@@ -72,7 +72,7 @@ const buildState = (nodes: unknown[], edges: unknown[]) =>
}) as unknown as Parameters[0];
describe('buildNodesGraph', () => {
- it('includes dynamic saved workflow inputs when templates are stored on the node', () => {
+ it('serializes dynamic saved workflow inputs into workflow_inputs', () => {
const state = nodesSliceConfig.getInitialState();
const node = buildNode(callSavedWorkflowTemplate);
state.nodes.push(node);
@@ -109,7 +109,59 @@ describe('buildNodesGraph', () => {
expect(graph.nodes[node.id]).toMatchObject({
workflow_id: '',
- ['saved_workflow_input::node-1::a']: 23,
+ workflow_inputs: {
+ ['saved_workflow_input::node-1::a']: 23,
+ },
+ });
+ });
+
+ it('omits connected dynamic saved workflow literal values from workflow_inputs while preserving the edge', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const sourceNode = buildNode(add);
+ const callNode = buildNode(callSavedWorkflowTemplate);
+ state.nodes.push(sourceNode, callNode);
+
+ const nextState = deepClone(
+ nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: callNode.id,
+ fields: [
+ {
+ fieldName: 'saved_workflow_input::node-1::a',
+ fieldTemplate: buildDynamicIntegerTemplate('saved_workflow_input::node-1::a'),
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ edgeIdsToRemove: [],
+ })
+ )
+ );
+
+ nextState.edges.push(buildEdge(sourceNode.id, 'value', callNode.id, 'saved_workflow_input::node-1::a'));
+
+ const rootState = {
+ nodes: {
+ past: [],
+ future: [],
+ present: nextState,
+ },
+ gallery: {
+ autoAddBoardId: 'none',
+ },
+ } as never;
+
+ const graph = buildNodesGraph(rootState, templates);
+
+ expect(graph.nodes[callNode.id]).toMatchObject({
+ workflow_id: '',
+ workflow_inputs: {},
+ });
+ expect(graph.edges).toContainEqual({
+ source: { node_id: sourceNode.id, field: 'value' },
+ destination: { node_id: callNode.id, field: 'saved_workflow_input::node-1::a' },
});
});
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
index 593b2ad5adc..0cd28901dad 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
@@ -18,6 +18,7 @@ import type { AnyInvocation, Graph } from 'services/api/types';
import { v4 as uuidv4 } from 'uuid';
const log = logger('workflows');
+const CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX = 'saved_workflow_input::';
const getBoardField = (field: BoardFieldInputInstance, state: RootState): BoardField | undefined => {
// Translate the UI value to the graph value. See note in BoardFieldInputComponent for more info.
@@ -64,6 +65,15 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require
const transformedInputs = reduce(
inputs,
(inputsAccumulator, input, name) => {
+ if (type === 'call_saved_workflow' && name.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX)) {
+ const workflowInputs = {
+ ...((inputsAccumulator['workflow_inputs'] as Record | undefined) ?? {}),
+ };
+ workflowInputs[name] = input.value;
+ inputsAccumulator['workflow_inputs'] = workflowInputs;
+ return inputsAccumulator;
+ }
+
const fieldTemplate = getInvocationNodeInputTemplate(data, nodeTemplate, name);
if (!fieldTemplate) {
log.warn({ id, name }, 'Field template not found!');
@@ -186,7 +196,20 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require
*/
parsedEdges.forEach((edge) => {
const destination_node = parsedNodes[edge.destination.node_id];
+ if (!destination_node) {
+ return;
+ }
const field = edge.destination.field;
+ const destinationNodeRecord = destination_node as Record;
+ if (
+ destination_node.type === 'call_saved_workflow' &&
+ field.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX) &&
+ typeof destinationNodeRecord['workflow_inputs'] === 'object' &&
+ destinationNodeRecord['workflow_inputs'] !== null
+ ) {
+ delete (destinationNodeRecord['workflow_inputs'] as Record)[field];
+ return;
+ }
parsedNodes[edge.destination.node_id] = omit(destination_node, field) as AnyInvocation;
});
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index a8739e3224f..c91ec6b68ce 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -237,10 +237,13 @@ def test_call_saved_workflow_invocation_schema_declares_saved_workflow_ui_type()
schema = CallSavedWorkflowInvocation.model_json_schema()
workflow_id = schema["properties"]["workflow_id"]
+ workflow_inputs = schema["properties"]["workflow_inputs"]
assert workflow_id["default"] == ""
assert workflow_id["input"] == "any"
assert workflow_id["ui_type"] == "SavedWorkflowField"
+ assert workflow_inputs["default"] == {}
+ assert workflow_inputs["ui_hidden"] is True
def test_workflow_return_invocation_contract():
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index 2517ddd1390..10ef43a8fd1 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -69,6 +69,8 @@ class _DummyConfig:
class _DummyWorkflowRecords:
def __init__(self) -> None:
self.return_invalid_workflow = False
+ self.return_batch_special_workflow = False
+ self.exposed_field_name = "a"
def get(self, workflow_id: str):
workflow = SimpleNamespace(
@@ -79,7 +81,7 @@ def get(self, workflow_id: str):
contact="",
tags="",
notes="",
- exposedFields=[],
+ exposedFields=[{"nodeId": "child-add", "fieldName": self.exposed_field_name}],
meta=SimpleNamespace(category=WorkflowCategory.User),
form=None,
nodes=[
@@ -145,6 +147,42 @@ def get(self, workflow_id: str):
],
"form": workflow.form,
}
+ if self.return_batch_special_workflow:
+ workflow.model_dump = lambda: {
+ "name": workflow.name,
+ "author": workflow.author,
+ "description": workflow.description,
+ "version": workflow.version,
+ "contact": workflow.contact,
+ "tags": workflow.tags,
+ "notes": workflow.notes,
+ "exposedFields": workflow.exposedFields,
+ "meta": {"category": workflow.meta.category, "version": "1.0.0"},
+ "nodes": [
+ {
+ "id": "child-image-batch",
+ "type": "invocation",
+ "position": {"x": 0, "y": 0},
+ "data": {
+ "id": "child-image-batch",
+ "type": "image_batch",
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "isOpen": True,
+ "isIntermediate": False,
+ "useCache": True,
+ "dynamicInputTemplates": {},
+ "inputs": {
+ "images": {"value": []},
+ },
+ },
+ }
+ ],
+ "edges": [],
+ "form": workflow.form,
+ }
return SimpleNamespace(
user_id="user-1",
is_public=False,
@@ -281,6 +319,8 @@ def __init__(self, invocation_id: str) -> None:
self.waiting: WorkflowCallFrame | None = None
self.waiting_workflow_call_child_session = None
self.errors: dict[str, str] = {}
+ self.execution_graph = Graph()
+ self.results = {}
def build_workflow_call_frame(self, exec_node_id: str, workflow_id: str) -> WorkflowCallFrame:
frame = WorkflowCallFrame(
@@ -301,6 +341,10 @@ def create_child_workflow_execution_state(self, graph: Graph, frame: WorkflowCal
def attach_waiting_workflow_call_child_session(self, child_session: GraphExecutionState) -> None:
self.waiting_workflow_call_child_session = child_session
+ def end_waiting_on_workflow_call(self) -> None:
+ self.waiting = None
+ self.waiting_workflow_call_child_session = None
+
def complete(self, node_id: str, output) -> None:
self.completed.append((node_id, output))
@@ -450,7 +494,7 @@ def test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch:
assert session_queue.completed_item_ids == []
-def test_run_node_transitions_call_saved_workflow_into_waiting_state(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_run_node_executes_child_workflow_and_clears_waiting_state(monkeypatch: pytest.MonkeyPatch) -> None:
runner, events, _workflow_records = _build_workflow_runner(monkeypatch)
invocation = CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a")
session = _WorkflowCallBoundarySession(invocation.id)
@@ -474,13 +518,45 @@ def test_run_node_transitions_call_saved_workflow_into_waiting_state(monkeypatch
runner.run_node(invocation=invocation, queue_item=queue_item)
assert len(session.frames) == 1
- assert session.waiting == session.frames[0]
+ assert session.waiting is None
assert session.frames[0].prepared_call_node_id == invocation.id
assert session.frames[0].workflow_id == "workflow-a"
+ assert len(session.completed) == 1
+ assert len(events.started) == 2
+ assert len(events.completed) == 2
+ assert events.errors == []
+
+
+def test_run_node_fails_cleanly_for_unsupported_batch_special_child_workflow(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ runner, events, workflow_records = _build_workflow_runner(monkeypatch)
+ workflow_records.return_batch_special_workflow = True
+ invocation = CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a")
+ session = _WorkflowCallBoundarySession(invocation.id)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "session_id": "test-session",
+ "user_id": "user-1",
+ "status": "in_progress",
+ "session": session,
+ },
+ )()
+
+ runner.run_node(invocation=invocation, queue_item=queue_item)
+
+ assert session.waiting is None
+ assert session.waiting_workflow_call_child_session is None
assert session.completed == []
assert len(events.started) == 1
assert events.completed == []
- assert events.errors == []
+ assert len(events.errors) == 1
+ _queue_item, _invocation, error_type, error_message, _traceback = events.errors[0]
+ assert error_type == "UnsupportedWorkflowNodeError"
+ assert "call_saved_workflow does not yet support batch-special" in error_message
def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -520,7 +596,7 @@ def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch:
assert session_queue.completed_item_ids == []
-def test_run_pauses_on_call_saved_workflow_and_does_not_run_downstream_nodes(
+def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_queue = _DummySessionQueue()
@@ -546,18 +622,21 @@ def test_run_pauses_on_call_saved_workflow_and_does_not_run_downstream_nodes(
runner.run(queue_item=queue_item)
- assert session.is_waiting_on_workflow_call()
- assert session.results == {}
- assert "downstream-add" not in session.executed
- assert len(events.started) == 1
- assert events.started[0][1].get_type() == "call_saved_workflow"
- assert events.completed == []
+ assert not session.is_waiting_on_workflow_call()
+ assert "downstream-add" in session.executed
+ assert len(events.started) == 3
+ assert [invocation.get_type() for _queue_item, invocation in events.started] == [
+ "call_saved_workflow",
+ "add",
+ "add",
+ ]
+ assert len(events.completed) == 3
assert events.errors == []
- assert session_queue.completed_item_ids == []
+ assert session_queue.completed_item_ids == [1]
assert session_queue.session_updates == [(1, session)]
-def test_run_node_creates_child_execution_state_for_waiting_workflow_call(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
runner, events, _workflow_records = _build_workflow_runner(monkeypatch)
graph = Graph()
@@ -579,15 +658,167 @@ def test_run_node_creates_child_execution_state_for_waiting_workflow_call(monkey
runner.run_node(invocation=invocation, queue_item=queue_item)
- assert session.is_waiting_on_workflow_call()
- assert session.waiting_workflow_call_child_session is not None
- assert session.waiting_workflow_call_child_session.graph.nodes["child-add"].get_type() == "add"
- assert session.waiting_workflow_call_child_session.workflow_call_stack == [session.waiting_workflow_call]
- assert len(events.started) == 1
- assert events.completed == []
+ assert not session.is_waiting_on_workflow_call()
+ assert session.waiting_workflow_call_child_session is None
+ assert invocation.id in session.executed
+ assert len(events.started) == 2
+ assert len(events.completed) == 2
+ assert events.errors == []
+
+
+def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ assert not session.is_waiting_on_workflow_call()
+ assert session_queue.completed_item_ids == [1]
+ assert "call-node" in session.executed
+ child_add_outputs = [
+ output
+ for invocation, child_queue_item, output in events.completed
+ if invocation.get_type() == "add"
+ and child_queue_item.session.prepared_source_mapping[invocation.id] == "child-add"
+ ]
+ assert len(child_add_outputs) == 1
+ assert child_add_outputs[0].value == 3
+ parent_outputs = [
+ output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
+ ]
+ assert len(parent_outputs) == 1
+ assert parent_outputs[0].value == 0
+ assert events.errors == []
+
+
+def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(
+ CallSavedWorkflowInvocation(
+ id="call-node",
+ workflow_id="workflow-a",
+ workflow_inputs={"saved_workflow_input::child-add::a": 7},
+ )
+ )
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ child_add_outputs = [
+ output
+ for invocation, child_queue_item, output in events.completed
+ if invocation.get_type() == "add"
+ and child_queue_item.session.prepared_source_mapping[invocation.id] == "child-add"
+ ]
+ assert len(child_add_outputs) == 1
+ assert child_add_outputs[0].value == 9
+ assert session_queue.completed_item_ids == [1]
assert events.errors == []
+def test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(AddInvocation(id="source-add", a=2, b=3))
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ graph.add_edge(create_edge("source-add", "value", "call-node", "saved_workflow_input::child-add::a"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ child_add_outputs = [
+ output
+ for invocation, child_queue_item, output in events.completed
+ if invocation.get_type() == "add"
+ and child_queue_item.session.prepared_source_mapping[invocation.id] == "child-add"
+ ]
+ assert len(child_add_outputs) == 1
+ assert child_add_outputs[0].value == 7
+ assert session_queue.completed_item_ids == [1]
+ assert events.errors == []
+
+
+def test_run_rejects_non_exposed_dynamic_workflow_inputs(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ workflow_records.exposed_field_name = "a"
+
+ graph = Graph()
+ graph.add_node(
+ CallSavedWorkflowInvocation(
+ id="call-node",
+ workflow_id="workflow-a",
+ workflow_inputs={"saved_workflow_input::child-add::b": 11},
+ )
+ )
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ assert session.has_error()
+ assert session_queue.failed_item_ids == [1]
+ assert events.completed == []
+ assert len(events.errors) == 1
+ assert "not exposed" in events.errors[0][3]
+
+
def test_run_fails_call_saved_workflow_when_child_workflow_graph_cannot_be_built(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/tests/app/services/test_workflow_graph_builder.py b/tests/app/services/test_workflow_graph_builder.py
index 4b294d24b31..9297e6bdde1 100644
--- a/tests/app/services/test_workflow_graph_builder.py
+++ b/tests/app/services/test_workflow_graph_builder.py
@@ -1,5 +1,10 @@
+import pytest
+
from invokeai.app.services.shared.graph import Graph
-from invokeai.app.services.shared.workflow_graph_builder import build_graph_from_workflow
+from invokeai.app.services.shared.workflow_graph_builder import (
+ UnsupportedWorkflowNodeError,
+ build_graph_from_workflow,
+)
def _build_workflow_node(
@@ -113,3 +118,13 @@ def test_build_graph_from_workflow_flattens_connector_edges():
assert edge.destination.field == "a"
assert graph.nodes["add-2"].a == 0
assert graph.nodes["add-2"].b == 3
+
+
+def test_build_graph_from_workflow_rejects_batch_special_nodes_with_clear_error():
+ workflow = _build_workflow(
+ nodes=[_build_workflow_node("image-batch-1", "image_batch", {"images": []})],
+ edges=[],
+ )
+
+ with pytest.raises(UnsupportedWorkflowNodeError, match="call_saved_workflow does not yet support batch-special"):
+ build_graph_from_workflow(workflow)
From 20f63d530093d4007760f45a887a82ed2d64f944 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 13:52:13 -0500
Subject: [PATCH 040/100] chore: ruff
---
invokeai/app/services/shared/workflow_graph_builder.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/invokeai/app/services/shared/workflow_graph_builder.py b/invokeai/app/services/shared/workflow_graph_builder.py
index 94431d4f494..1cd0e31c0f0 100644
--- a/invokeai/app/services/shared/workflow_graph_builder.py
+++ b/invokeai/app/services/shared/workflow_graph_builder.py
@@ -110,7 +110,9 @@ def get_exposed_workflow_input_names(workflow: Mapping[str, Any]) -> set[str]:
return fallback_inputs
-def apply_workflow_inputs_to_graph(graph: Graph, workflow: Mapping[str, Any], workflow_inputs: Mapping[str, Any]) -> None:
+def apply_workflow_inputs_to_graph(
+ graph: Graph, workflow: Mapping[str, Any], workflow_inputs: Mapping[str, Any]
+) -> None:
if not workflow_inputs:
return
From 9fb18d66fb6d2fac859abbe1be1bd3ffd87a63d7 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 14:17:38 -0500
Subject: [PATCH 041/100] Test inline saved workflow execution semantics
---
docs/contributing/call_saved_workflow.md | 8 +-
.../test_session_processor_shutdown.py | 371 ++++++++++++++----
2 files changed, 290 insertions(+), 89 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 7f415a39ce7..b774501a6ee 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -196,6 +196,10 @@ Current limitation:
clear unsupported-feature error
- the current child execution path runs inline inside `DefaultSessionRunner`, so child execution is not yet a
first-class queue/session entity
+- the current inline runner path is an intentionally temporary implementation step used to keep the feature testable
+ while the durable parent-child execution architecture is being built
+- the plan is to replace the inline runner path with a first-class parent-child execution mechanism once return-value
+ propagation and child-session lifecycle handling are implemented cleanly
### 5. Return Values
@@ -273,8 +277,8 @@ Current insertion points already used:
Next runtime work still needed:
-- decide whether the inline child-session execution should remain temporarily or move to a more explicit parent-child
- runtime boundary
+- move child execution off the temporary inline runner path and onto the intended first-class parent-child runtime
+ boundary once return propagation is in place
- persist and/or formalize parent-child identifiers beyond the in-memory attached child session
- define how the child completion or failure is delivered back to the suspended parent
- complete the call node from child results
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index 10ef43a8fd1..8e8b83ef22e 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -1,6 +1,7 @@
from contextlib import contextmanager
from threading import Event
from types import SimpleNamespace
+from typing import Any
import pytest
@@ -72,69 +73,68 @@ def __init__(self) -> None:
self.return_batch_special_workflow = False
self.exposed_field_name = "a"
+ @staticmethod
+ def _invocation_node(node_id: str, invocation_type: str, inputs: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": node_id,
+ "type": "invocation",
+ "position": {"x": 0, "y": 0},
+ "data": {
+ "id": node_id,
+ "type": invocation_type,
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "isOpen": True,
+ "isIntermediate": False,
+ "useCache": True,
+ "dynamicInputTemplates": {},
+ "inputs": inputs,
+ },
+ }
+
+ @classmethod
+ def _workflow_dump(
+ cls,
+ *,
+ nodes: list[dict[str, Any]],
+ edges: list[dict[str, Any]],
+ exposed_fields: list[dict[str, str]] | None = None,
+ ) -> dict[str, Any]:
+ return {
+ "name": "Child Workflow",
+ "author": "Tester",
+ "description": "",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "",
+ "notes": "",
+ "exposedFields": exposed_fields or [],
+ "meta": {"category": WorkflowCategory.User, "version": "1.0.0"},
+ "nodes": nodes,
+ "edges": edges,
+ "form": None,
+ }
+
def get(self, workflow_id: str):
- workflow = SimpleNamespace(
- name="Child Workflow",
- author="Tester",
- description="",
- version="1.0.0",
- contact="",
- tags="",
- notes="",
- exposedFields=[{"nodeId": "child-add", "fieldName": self.exposed_field_name}],
- meta=SimpleNamespace(category=WorkflowCategory.User),
- form=None,
+ workflow_dump = self._workflow_dump(
nodes=[
- {
- "id": "child-add",
- "type": "invocation",
- "position": {"x": 0, "y": 0},
- "data": {
- "id": "child-add",
- "type": "add",
- "version": "1.0.0",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "isOpen": True,
- "isIntermediate": False,
- "useCache": True,
- "dynamicInputTemplates": {},
- "inputs": {
- "a": {"value": 1},
- "b": {"value": 2},
- },
+ self._invocation_node(
+ "child-add",
+ "add",
+ {
+ "a": {"value": 1},
+ "b": {"value": 2},
},
- }
+ )
],
edges=[],
+ exposed_fields=[{"nodeId": "child-add", "fieldName": self.exposed_field_name}],
)
- workflow.model_dump = lambda: {
- "name": workflow.name,
- "author": workflow.author,
- "description": workflow.description,
- "version": workflow.version,
- "contact": workflow.contact,
- "tags": workflow.tags,
- "notes": workflow.notes,
- "exposedFields": workflow.exposedFields,
- "meta": {"category": workflow.meta.category, "version": "1.0.0"},
- "nodes": workflow.nodes,
- "edges": workflow.edges,
- "form": workflow.form,
- }
if self.return_invalid_workflow:
- workflow.model_dump = lambda: {
- "name": workflow.name,
- "author": workflow.author,
- "description": workflow.description,
- "version": workflow.version,
- "contact": workflow.contact,
- "tags": workflow.tags,
- "notes": workflow.notes,
- "exposedFields": workflow.exposedFields,
- "meta": {"category": workflow.meta.category, "version": "1.0.0"},
- "nodes": workflow.nodes,
+ workflow_dump = {
+ **workflow_dump,
"edges": [
{
"id": "edge-invalid",
@@ -145,44 +145,119 @@ def get(self, workflow_id: str):
"targetHandle": "missing_input",
}
],
- "form": workflow.form,
}
if self.return_batch_special_workflow:
- workflow.model_dump = lambda: {
- "name": workflow.name,
- "author": workflow.author,
- "description": workflow.description,
- "version": workflow.version,
- "contact": workflow.contact,
- "tags": workflow.tags,
- "notes": workflow.notes,
- "exposedFields": workflow.exposedFields,
- "meta": {"category": workflow.meta.category, "version": "1.0.0"},
+ workflow_dump = {
+ **workflow_dump,
"nodes": [
- {
- "id": "child-image-batch",
- "type": "invocation",
- "position": {"x": 0, "y": 0},
- "data": {
- "id": "child-image-batch",
- "type": "image_batch",
- "version": "1.0.0",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "isOpen": True,
- "isIntermediate": False,
- "useCache": True,
- "dynamicInputTemplates": {},
- "inputs": {
- "images": {"value": []},
- },
+ self._invocation_node(
+ "child-image-batch",
+ "image_batch",
+ {
+ "images": {"value": []},
},
- }
+ )
],
"edges": [],
- "form": workflow.form,
}
+ if workflow_id == "workflow-dependent":
+ workflow_dump = self._workflow_dump(
+ nodes=[
+ self._invocation_node("child-add-1", "add", {"a": {"value": 1}, "b": {"value": 2}}),
+ self._invocation_node("child-add-2", "add", {"a": {"value": 0}, "b": {"value": 4}}),
+ ],
+ edges=[
+ {
+ "id": "edge-dependent",
+ "type": "default",
+ "source": "child-add-1",
+ "sourceHandle": "value",
+ "target": "child-add-2",
+ "targetHandle": "a",
+ }
+ ],
+ )
+ elif workflow_id == "workflow-if":
+ workflow_dump = self._workflow_dump(
+ nodes=[
+ self._invocation_node("child-bool", "boolean", {"value": {"value": True}}),
+ self._invocation_node("child-add", "add", {"a": {"value": 2}, "b": {"value": 3}}),
+ self._invocation_node(
+ "child-if",
+ "if",
+ {
+ "condition": {"value": False},
+ "true_input": {"value": None},
+ "false_input": {"value": 11},
+ },
+ ),
+ ],
+ edges=[
+ {
+ "id": "edge-if-condition",
+ "type": "default",
+ "source": "child-bool",
+ "sourceHandle": "value",
+ "target": "child-if",
+ "targetHandle": "condition",
+ },
+ {
+ "id": "edge-if-true",
+ "type": "default",
+ "source": "child-add",
+ "sourceHandle": "value",
+ "target": "child-if",
+ "targetHandle": "true_input",
+ },
+ ],
+ )
+ elif workflow_id == "workflow-nested":
+ workflow_dump = self._workflow_dump(
+ nodes=[
+ self._invocation_node(
+ "nested-call",
+ "call_saved_workflow",
+ {
+ "workflow_id": {"value": "workflow-leaf"},
+ "workflow_inputs": {"value": {}},
+ },
+ ),
+ self._invocation_node("nested-add", "add", {"a": {"value": 0}, "b": {"value": 4}}),
+ ],
+ edges=[
+ {
+ "id": "edge-nested-downstream",
+ "type": "default",
+ "source": "nested-call",
+ "sourceHandle": "value",
+ "target": "nested-add",
+ "targetHandle": "a",
+ }
+ ],
+ )
+ elif workflow_id == "workflow-leaf":
+ workflow_dump = self._workflow_dump(
+ nodes=[
+ self._invocation_node("leaf-add", "add", {"a": {"value": 5}, "b": {"value": 6}}),
+ ],
+ edges=[],
+ )
+
+ workflow = SimpleNamespace(
+ name="Child Workflow",
+ author="Tester",
+ description="",
+ version="1.0.0",
+ contact="",
+ tags="",
+ notes="",
+ exposedFields=workflow_dump["exposedFields"],
+ meta=SimpleNamespace(category=WorkflowCategory.User),
+ form=workflow_dump["form"],
+ nodes=workflow_dump["nodes"],
+ edges=workflow_dump["edges"],
+ )
+ workflow.model_dump = lambda: workflow_dump
return SimpleNamespace(
user_id="user-1",
is_public=False,
@@ -707,6 +782,128 @@ def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch
assert events.errors == []
+def test_run_respects_child_dependency_readiness(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-dependent"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ child_completions = [
+ (child_queue_item.session.prepared_source_mapping[invocation.id], output)
+ for invocation, child_queue_item, output in events.completed
+ if child_queue_item.session is not session and invocation.get_type() == "add"
+ ]
+ assert [source_id for source_id, _output in child_completions] == ["child-add-1", "child-add-2"]
+ assert child_completions[0][1].value == 3
+ assert child_completions[1][1].value == 7
+ assert session_queue.completed_item_ids == [1]
+ assert events.errors == []
+
+
+def test_run_respects_child_if_branching(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-if"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ child_if_outputs = [
+ output
+ for invocation, child_queue_item, output in events.completed
+ if child_queue_item.session is not session and invocation.get_type() == "if"
+ ]
+ assert len(child_if_outputs) == 1
+ assert child_if_outputs[0].value == 5
+ assert session_queue.completed_item_ids == [1]
+ assert events.errors == []
+
+
+def test_run_supports_nested_call_saved_workflow_execution(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-nested"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ call_started = [
+ queue_item.session.prepared_source_mapping[invocation.id]
+ for queue_item, invocation in events.started
+ if invocation.get_type() == "call_saved_workflow"
+ ]
+ call_completed = [
+ queue_item.session.prepared_source_mapping[invocation.id]
+ for invocation, queue_item, _output in events.completed
+ if invocation.get_type() == "call_saved_workflow"
+ ]
+ nested_add_outputs = [
+ output
+ for invocation, child_queue_item, output in events.completed
+ if child_queue_item.session is not session
+ and child_queue_item.session.prepared_source_mapping[invocation.id] == "nested-add"
+ ]
+ leaf_add_outputs = [
+ output
+ for invocation, child_queue_item, output in events.completed
+ if child_queue_item.session is not session
+ and child_queue_item.session.prepared_source_mapping[invocation.id] == "leaf-add"
+ ]
+
+ assert call_started == ["call-node", "nested-call"]
+ assert call_completed == ["nested-call", "call-node"]
+ assert len(leaf_add_outputs) == 1
+ assert leaf_add_outputs[0].value == 11
+ assert len(nested_add_outputs) == 1
+ assert nested_add_outputs[0].value == 4
+ assert session_queue.completed_item_ids == [1]
+ assert events.errors == []
+
+
def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
From 5152aa031e15c10465f523f431cdf080ecfef022 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 15:35:48 -0500
Subject: [PATCH 042/100] Fix saved workflow lint and schema warnings
---
.../app/invocations/call_saved_workflow.py | 2 +-
.../fields/inputs/savedWorkflowFieldUtils.ts | 2 +-
.../frontend/web/src/services/api/schema.ts | 49 +++++++++++++++++++
3 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/invokeai/app/invocations/call_saved_workflow.py b/invokeai/app/invocations/call_saved_workflow.py
index 97302216745..9c9b2a77b09 100644
--- a/invokeai/app/invocations/call_saved_workflow.py
+++ b/invokeai/app/invocations/call_saved_workflow.py
@@ -43,7 +43,7 @@ class CallSavedWorkflowInvocation(BaseInvocation):
ui_type=UIType.SavedWorkflow,
)
workflow_inputs: dict[str, Any] = InputField(
- default_factory=dict,
+ default={},
description="Literal values for the selected workflow's exposed inputs, managed by the workflow editor UI.",
ui_hidden=True,
)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index bea62f0f1f1..61a07670942 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -3,7 +3,7 @@ import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'
export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
-export type SavedWorkflowSelectionState =
+type SavedWorkflowSelectionState =
| { status: 'unselected' }
| { status: 'selected'; workflow: WorkflowRecordListItemWithThumbnailDTO }
| { status: 'missing'; workflowId: string };
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 12f485e3317..abfd89c7d8c 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -5015,6 +5015,14 @@ export type components = {
* @default
*/
workflow_id?: string;
+ /**
+ * Workflow Inputs
+ * @description Literal values for the selected workflow's exposed inputs, managed by the workflow editor UI.
+ * @default {}
+ */
+ workflow_inputs?: {
+ [key: string]: unknown;
+ };
/**
* type
* @default call_saved_workflow
@@ -11473,6 +11481,21 @@ export type components = {
errors: {
[key: string]: string;
};
+ /**
+ * Workflow Call Stack
+ * @description The nested workflow call stack inherited by this execution state.
+ */
+ workflow_call_stack: components["schemas"]["WorkflowCallFrame"][];
+ /** @description The child workflow call this execution state is currently waiting on, if any. */
+ waiting_workflow_call?: components["schemas"]["WorkflowCallFrame"] | null;
+ /** @description The child workflow execution state spawned by the current waiting workflow call, if any. */
+ waiting_workflow_call_child_session?: components["schemas"]["GraphExecutionState"] | null;
+ /**
+ * Max Workflow Call Depth
+ * @description The maximum permitted workflow call depth for nested workflow execution.
+ * @default 4
+ */
+ max_workflow_call_depth?: number;
/**
* Prepared Source Mapping
* @description The map of prepared nodes to original graph nodes
@@ -29523,6 +29546,32 @@ export type components = {
*/
graph: string | null;
};
+ /**
+ * WorkflowCallFrame
+ * @description Represents one workflow-call frame in a nested call chain.
+ */
+ WorkflowCallFrame: {
+ /**
+ * Prepared Call Node Id
+ * @description The prepared exec node id for the call site.
+ */
+ prepared_call_node_id: string;
+ /**
+ * Source Call Node Id
+ * @description The source graph node id for the call site.
+ */
+ source_call_node_id: string;
+ /**
+ * Workflow Id
+ * @description The saved workflow being called.
+ */
+ workflow_id: string;
+ /**
+ * Depth
+ * @description The 1-based depth of this call frame.
+ */
+ depth: number;
+ };
/**
* WorkflowCategory
* @enum {string}
From b3ec622a5ba4ede504023b4a4cfc7929b2a652c1 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 15:42:12 -0500
Subject: [PATCH 043/100] Updated documentation
---
invokeai/app/services/shared/README.md | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 2e31f148601..1d81f5b9fb5 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -58,6 +58,15 @@ Runs a sequence of checks:
1. **Type compatibility** `get_output_field_type` vs `get_input_field_type` and `are_connection_types_compatible`.
+ Special case:
+
+ - `call_saved_workflow` currently accepts dynamic destination handles of the form
+ `saved_workflow_input::{childNodeId}::{childFieldName}` as part of its temporary call-boundary contract.
+ - Those handles are allowed through graph validation even though they are not static Python model fields on the
+ invocation class.
+ - Runtime later validates them against the selected child workflow's exposed callable interface before applying
+ values to the child graph.
+
1. **Iterator / collector structure** Enforce special rules:
- Iterator's input must be `collection`; its outgoing edges use `item`.
@@ -122,6 +131,15 @@ mutation helpers. Those helpers reject changes once the affected nodes have alre
- `complete(node_id, output)` Records the result, marks the exec node executed, marks the source node executed once all
of its prepared exec copies are done, then decrements downstream indegrees and enqueues newly ready nodes.
+Workflow-call note:
+
+- `GraphExecutionState` can represent a paused parent execution plus an attached child execution state, but it does not
+ itself orchestrate child execution.
+- In the current temporary implementation, `DefaultSessionRunner.run_node()` runs the attached child session inline,
+ then clears the waiting state and completes the parent `call_saved_workflow` node with a stub output.
+- This is a tactical step to keep the feature testable and should eventually be replaced by a first-class parent/child
+ execution mechanism with explicit return propagation.
+
### 4.3 Runtime helper classes
`GraphExecutionState` now delegates most runtime behavior to internal helpers:
@@ -238,6 +256,12 @@ In normal execution, all runtime expansion occurs in `execution_graph` with trac
- **Workflow call boundaries**: `GraphExecutionState` can suspend a parent execution state on a workflow call, attach a
child execution state, and later resume the parent without mutating the source graph.
+Current limitation:
+
+- The attached child execution state is currently executed inline by the session runner rather than as a first-class
+ queued or scheduler-visible child execution.
+- Parent completion after a workflow call still uses a temporary stub output rather than `workflow_return` propagation.
+
## 8) Error Model (selected)
- `DuplicateNodeIdError`, `NodeAlreadyInGraphError`
From 3accbb25a5bad66d15285c34cea15e4dff901b3b Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Fri, 17 Apr 2026 19:12:06 -0500
Subject: [PATCH 044/100] Propagate workflow return values to callers
---
docs/contributing/call_saved_workflow.md | 21 +-
.../app/invocations/call_saved_workflow.py | 6 +-
.../session_processor_default.py | 30 ++-
invokeai/app/services/shared/README.md | 5 +-
.../frontend/web/src/services/api/schema.ts | 2 +-
.../invocations/test_call_saved_workflows.py | 12 +-
.../test_session_processor_shutdown.py | 248 ++++++++++++++++--
7 files changed, 279 insertions(+), 45 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index b774501a6ee..b3fb90ba958 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -66,7 +66,8 @@ Implemented runtime scaffolding:
- creates a child `GraphExecutionState`
- attaches that child session to the waiting parent session
- runs the child session inline in the runner
- - clears the waiting state and completes the parent `call_saved_workflow` node with the current stub output
+ - captures the child `workflow_return` output
+ - clears the waiting state and completes the parent `call_saved_workflow` node with that returned collection
- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
- Dynamic call arguments now execute end-to-end in the current runner path:
- literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node
@@ -85,8 +86,6 @@ What is still not implemented:
- the child workflow is executed inline by the parent runner, not yet as its own queue item or scheduler-visible unit
- the child workflow is not persisted as its own queue item
- parent-child queue/session identifiers are not yet formalized beyond the attached child `GraphExecutionState`
-- `workflow_return` is not yet captured and propagated back to the parent
-- the parent call node still completes with a temporary stub output rather than child return data
- saved workflows containing batch-special nodes such as `image_batch` are not supported by the current child graph
reconstruction path and must fail with a clear domain error rather than a low-level constructor error
@@ -94,8 +93,8 @@ Conclusion:
- the editor contract is largely in place
- the parent-side runtime call boundary is in place
-- child execution and argument forwarding are now in place through an inline runner path
-- return propagation and first-class child execution semantics are the remaining major runtime steps
+- child execution, argument forwarding, and explicit child return capture now work through the inline runner path
+- first-class child execution semantics are the remaining major runtime step
## Architectural Direction
@@ -273,7 +272,7 @@ Current insertion points already used:
- `DefaultSessionRunner.run_node()` detects `call_saved_workflow` and enters boundary state
- `GraphExecutionState` stores the waiting/call-stack state and attached child session
- `DefaultSessionRunner.run_node()` currently executes the attached child session inline before completing the parent
- call node with the stub output
+ call node with the child `workflow_return` collection
Next runtime work still needed:
@@ -281,7 +280,6 @@ Next runtime work still needed:
boundary once return propagation is in place
- persist and/or formalize parent-child identifiers beyond the in-memory attached child session
- define how the child completion or failure is delivered back to the suspended parent
-- complete the call node from child results
- replace the current unsupported batch-special-node limitation by routing child execution through machinery that can
honor ordinary Invoke batch semantics
@@ -368,12 +366,13 @@ Already covered:
- inline child execution completes the parent queue item instead of leaving it stuck in `in_progress`
- literal and connected dynamic call arguments are applied to the child graph at runtime
- non-exposed dynamic call arguments are rejected at runtime
+- child `workflow_return` output is captured and becomes the parent `call_saved_workflow` output
+- child workflows without a `workflow_return` node fail cleanly when called
Still needed in later increments:
- parent-child resume behavior once child execution is no longer an inline runner detail
- child failure propagation into parent failure
-- `workflow_return` capture and propagation
- nested runtime execution beyond a single attached child state
- eventual queue/session persistence rules if child executions become first-class queue items
- eventual support for child workflows that contain batch-special nodes, once child execution is run through the proper
@@ -383,7 +382,7 @@ Still needed in later increments:
The next incremental step should be:
-- replace the stub parent completion path with explicit `workflow_return` capture and propagation
+- move the temporary inline child execution path toward the intended first-class parent-child runtime model
- keep that step test-first
- preserve the current bounded nested-call runtime state while making return/resume behavior explicit
@@ -391,5 +390,5 @@ The current branch is at the point where:
- parent call-boundary state exists
- child execution state can be created from the selected saved workflow
-- child execution and argument forwarding work through the inline runner path
-- but explicit return propagation and long-term child-session execution semantics are still missing
+- child execution, argument forwarding, and explicit return propagation work through the inline runner path
+- but long-term child-session execution semantics are still missing
diff --git a/invokeai/app/invocations/call_saved_workflow.py b/invokeai/app/invocations/call_saved_workflow.py
index 9c9b2a77b09..7b01d8308dc 100644
--- a/invokeai/app/invocations/call_saved_workflow.py
+++ b/invokeai/app/invocations/call_saved_workflow.py
@@ -2,7 +2,7 @@
from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
from invokeai.app.invocations.fields import InputField, UIType
-from invokeai.app.invocations.primitives import IntegerOutput
+from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory, WorkflowNotFoundError
@@ -69,7 +69,7 @@ def validate_selected_workflow(self, context: InvocationContext):
return workflow_record
- def invoke(self, context: InvocationContext) -> IntegerOutput:
+ def invoke(self, context: InvocationContext) -> WorkflowReturnOutput:
self.validate_selected_workflow(context)
- return IntegerOutput(value=0)
+ return WorkflowReturnOutput(collection=[])
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index b2c983f4bcf..45511bac4aa 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -10,7 +10,7 @@
CallSavedWorkflowInvocation,
is_call_saved_workflow_dynamic_input,
)
-from invokeai.app.invocations.primitives import IntegerOutput
+from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
from invokeai.app.services.events.events_common import (
BatchEnqueuedEvent,
FastAPIEvent,
@@ -33,7 +33,7 @@
)
from invokeai.app.services.session_processor.session_processor_common import CanceledException, SessionProcessorStatus
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem, SessionQueueItemNotFoundError
-from invokeai.app.services.shared.graph import NodeInputError
+from invokeai.app.services.shared.graph import GraphExecutionState, NodeInputError
from invokeai.app.services.shared.invocation_context import InvocationContextData, build_invocation_context
from invokeai.app.services.shared.workflow_graph_builder import (
apply_workflow_inputs_to_graph,
@@ -135,6 +135,30 @@ def _build_child_queue_item(queue_item: SessionQueueItem, child_session) -> Sess
child_queue_item.__dict__ = {**queue_item.__dict__, "session": child_session, "session_id": child_session.id}
return child_queue_item
+ @staticmethod
+ def _get_child_workflow_return_output(child_session: GraphExecutionState) -> WorkflowReturnOutput:
+ workflow_return_node_ids = [
+ node_id for node_id, node in child_session.graph.nodes.items() if node.get_type() == "workflow_return"
+ ]
+ if not workflow_return_node_ids:
+ raise ValueError("The selected saved workflow must contain exactly one workflow_return node.")
+ if len(workflow_return_node_ids) > 1:
+ raise ValueError("The selected saved workflow must not contain more than one workflow_return node.")
+
+ workflow_return_node_id = workflow_return_node_ids[0]
+ prepared_return_node_ids = child_session.source_prepared_mapping.get(workflow_return_node_id, set())
+ if len(prepared_return_node_ids) != 1:
+ raise ValueError(
+ "The selected saved workflow produced an unsupported number of workflow_return executions."
+ )
+
+ prepared_return_node_id = next(iter(prepared_return_node_ids))
+ output = child_session.results.get(prepared_return_node_id)
+ if not isinstance(output, WorkflowReturnOutput):
+ raise ValueError("The selected saved workflow did not produce a valid workflow_return output.")
+
+ return output
+
def run(self, queue_item: SessionQueueItem):
# Exceptions raised outside `run_node` are handled by the processor. There is no need to catch them here.
@@ -178,8 +202,8 @@ def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem):
f"The selected saved workflow '{invocation.workflow_id}' failed during child execution."
)
+ output = self._get_child_workflow_return_output(child_session)
queue_item.session.end_waiting_on_workflow_call()
- output = IntegerOutput(value=0)
queue_item.session.complete(invocation.id, output)
self._on_after_run_node(invocation, queue_item, output)
return
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 1d81f5b9fb5..edf767e2297 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -136,7 +136,8 @@ Workflow-call note:
- `GraphExecutionState` can represent a paused parent execution plus an attached child execution state, but it does not
itself orchestrate child execution.
- In the current temporary implementation, `DefaultSessionRunner.run_node()` runs the attached child session inline,
- then clears the waiting state and completes the parent `call_saved_workflow` node with a stub output.
+ captures the child `workflow_return` output, then clears the waiting state and completes the parent
+ `call_saved_workflow` node with that returned collection.
- This is a tactical step to keep the feature testable and should eventually be replaced by a first-class parent/child
execution mechanism with explicit return propagation.
@@ -260,7 +261,7 @@ Current limitation:
- The attached child execution state is currently executed inline by the session runner rather than as a first-class
queued or scheduler-visible child execution.
-- Parent completion after a workflow call still uses a temporary stub output rather than `workflow_return` propagation.
+- Called workflows currently require a valid `workflow_return` node to produce a parent-visible result.
## 8) Error Model (selected)
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index abfd89c7d8c..1eabfe588ea 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -14839,7 +14839,7 @@ export type components = {
calculate_image_tiles: components["schemas"]["CalculateImageTilesOutput"];
calculate_image_tiles_even_split: components["schemas"]["CalculateImageTilesOutput"];
calculate_image_tiles_min_overlap: components["schemas"]["CalculateImageTilesOutput"];
- call_saved_workflow: components["schemas"]["IntegerOutput"];
+ call_saved_workflow: components["schemas"]["WorkflowReturnOutput"];
canny_edge_detection: components["schemas"]["ImageOutput"];
canvas_output: components["schemas"]["ImageOutput"];
canvas_paste_back: components["schemas"]["ImageOutput"];
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index c91ec6b68ce..32e1bcac541 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -93,7 +93,7 @@ def build_context(
def test_call_saved_workflow_invocation_contract():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
- from invokeai.app.invocations.primitives import IntegerOutput
+ from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="workflow-123")
@@ -102,8 +102,8 @@ def test_call_saved_workflow_invocation_contract():
output = invocation.invoke(build_context())
- assert isinstance(output, IntegerOutput)
- assert output.value == 0
+ assert isinstance(output, WorkflowReturnOutput)
+ assert output.collection == []
def test_call_saved_workflow_invocation_raises_when_workflow_id_is_empty():
@@ -164,7 +164,7 @@ def test_call_saved_workflow_invocation_allows_shared_workflow_for_non_owner():
)
)
- assert output.value == 0
+ assert output.collection == []
def test_call_saved_workflow_invocation_allows_default_workflow_for_non_owner():
@@ -186,7 +186,7 @@ def test_call_saved_workflow_invocation_allows_default_workflow_for_non_owner():
)
)
- assert output.value == 0
+ assert output.collection == []
def test_call_saved_workflow_invocation_allows_admin_to_access_private_workflow():
@@ -208,7 +208,7 @@ def test_call_saved_workflow_invocation_allows_admin_to_access_private_workflow(
)
)
- assert output.value == 0
+ assert output.collection == []
def test_call_saved_workflow_invocation_raises_when_private_workflow_user_record_is_missing():
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index 8e8b83ef22e..7f9a77f03f3 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -7,7 +7,9 @@
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
+from invokeai.app.invocations.logic import IfInvocation
from invokeai.app.invocations.math import AddInvocation
+from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
from invokeai.app.services.session_processor.session_processor_default import DefaultSessionRunner
from invokeai.app.services.shared.graph import Graph, GraphExecutionState, WorkflowCallFrame
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory
@@ -127,9 +129,28 @@ def get(self, workflow_id: str):
"a": {"value": 1},
"b": {"value": 2},
},
- )
+ ),
+ self._invocation_node(
+ "child-collection",
+ "integer_collection",
+ {"collection": {"value": [3]}},
+ ),
+ self._invocation_node(
+ "child-return",
+ "workflow_return",
+ {"collection": {"value": []}},
+ ),
+ ],
+ edges=[
+ {
+ "id": "edge-default-return",
+ "type": "default",
+ "source": "child-collection",
+ "sourceHandle": "collection",
+ "target": "child-return",
+ "targetHandle": "collection",
+ }
],
- edges=[],
exposed_fields=[{"nodeId": "child-add", "fieldName": self.exposed_field_name}],
)
if self.return_invalid_workflow:
@@ -165,6 +186,16 @@ def get(self, workflow_id: str):
nodes=[
self._invocation_node("child-add-1", "add", {"a": {"value": 1}, "b": {"value": 2}}),
self._invocation_node("child-add-2", "add", {"a": {"value": 0}, "b": {"value": 4}}),
+ self._invocation_node(
+ "child-collection",
+ "integer_collection",
+ {"collection": {"value": [7]}},
+ ),
+ self._invocation_node(
+ "child-return",
+ "workflow_return",
+ {"collection": {"value": []}},
+ ),
],
edges=[
{
@@ -174,7 +205,15 @@ def get(self, workflow_id: str):
"sourceHandle": "value",
"target": "child-add-2",
"targetHandle": "a",
- }
+ },
+ {
+ "id": "edge-dependent-return",
+ "type": "default",
+ "source": "child-collection",
+ "sourceHandle": "collection",
+ "target": "child-return",
+ "targetHandle": "collection",
+ },
],
)
elif workflow_id == "workflow-if":
@@ -182,6 +221,11 @@ def get(self, workflow_id: str):
nodes=[
self._invocation_node("child-bool", "boolean", {"value": {"value": True}}),
self._invocation_node("child-add", "add", {"a": {"value": 2}, "b": {"value": 3}}),
+ self._invocation_node(
+ "child-collection",
+ "integer_collection",
+ {"collection": {"value": [5]}},
+ ),
self._invocation_node(
"child-if",
"if",
@@ -191,6 +235,11 @@ def get(self, workflow_id: str):
"false_input": {"value": 11},
},
),
+ self._invocation_node(
+ "child-return",
+ "workflow_return",
+ {"collection": {"value": []}},
+ ),
],
edges=[
{
@@ -209,6 +258,14 @@ def get(self, workflow_id: str):
"target": "child-if",
"targetHandle": "true_input",
},
+ {
+ "id": "edge-if-return",
+ "type": "default",
+ "source": "child-collection",
+ "sourceHandle": "collection",
+ "target": "child-return",
+ "targetHandle": "collection",
+ },
],
)
elif workflow_id == "workflow-nested":
@@ -223,15 +280,25 @@ def get(self, workflow_id: str):
},
),
self._invocation_node("nested-add", "add", {"a": {"value": 0}, "b": {"value": 4}}),
+ self._invocation_node(
+ "nested-collection",
+ "integer_collection",
+ {"collection": {"value": [4]}},
+ ),
+ self._invocation_node(
+ "nested-return",
+ "workflow_return",
+ {"collection": {"value": []}},
+ ),
],
edges=[
{
- "id": "edge-nested-downstream",
+ "id": "edge-nested-return",
"type": "default",
- "source": "nested-call",
- "sourceHandle": "value",
- "target": "nested-add",
- "targetHandle": "a",
+ "source": "nested-collection",
+ "sourceHandle": "collection",
+ "target": "nested-return",
+ "targetHandle": "collection",
}
],
)
@@ -239,8 +306,67 @@ def get(self, workflow_id: str):
workflow_dump = self._workflow_dump(
nodes=[
self._invocation_node("leaf-add", "add", {"a": {"value": 5}, "b": {"value": 6}}),
+ self._invocation_node(
+ "leaf-collection",
+ "integer_collection",
+ {"collection": {"value": [11]}},
+ ),
+ self._invocation_node(
+ "leaf-return",
+ "workflow_return",
+ {"collection": {"value": []}},
+ ),
+ ],
+ edges=[
+ {
+ "id": "edge-leaf-return",
+ "type": "default",
+ "source": "leaf-collection",
+ "sourceHandle": "collection",
+ "target": "leaf-return",
+ "targetHandle": "collection",
+ }
+ ],
+ )
+ elif workflow_id == "workflow-return":
+ workflow_dump = self._workflow_dump(
+ nodes=[
+ self._invocation_node(
+ "child-collection",
+ "integer_collection",
+ {"collection": {"value": [7, 8]}},
+ ),
+ self._invocation_node(
+ "child-return",
+ "workflow_return",
+ {"collection": {"value": []}},
+ ),
+ ],
+ edges=[
+ {
+ "id": "edge-return-collection",
+ "type": "default",
+ "source": "child-collection",
+ "sourceHandle": "collection",
+ "target": "child-return",
+ "targetHandle": "collection",
+ }
+ ],
+ )
+ elif workflow_id == "workflow-no-return":
+ workflow_dump = self._workflow_dump(
+ nodes=[
+ self._invocation_node(
+ "child-add",
+ "add",
+ {
+ "a": {"value": 1},
+ "b": {"value": 2},
+ },
+ )
],
edges=[],
+ exposed_fields=[{"nodeId": "child-add", "fieldName": self.exposed_field_name}],
)
workflow = SimpleNamespace(
@@ -580,6 +706,7 @@ def test_run_node_executes_child_workflow_and_clears_waiting_state(monkeypatch:
"item_id": 1,
"session_id": "test-session",
"user_id": "user-1",
+ "status": "in_progress",
"session": session,
},
)()
@@ -597,8 +724,10 @@ def test_run_node_executes_child_workflow_and_clears_waiting_state(monkeypatch:
assert session.frames[0].prepared_call_node_id == invocation.id
assert session.frames[0].workflow_id == "workflow-a"
assert len(session.completed) == 1
- assert len(events.started) == 2
- assert len(events.completed) == 2
+ assert isinstance(session.completed[0][1], WorkflowReturnOutput)
+ assert session.completed[0][1].collection == [3]
+ assert len(events.started) == 4
+ assert len(events.completed) == 4
assert events.errors == []
@@ -679,8 +808,8 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
- graph.add_node(AddInvocation(id="downstream-add", b=2))
- graph.add_edge(create_edge("call-node", "value", "downstream-add", "a"))
+ graph.add_node(IfInvocation(id="downstream-if", condition=True, false_input=0))
+ graph.add_edge(create_edge("call-node", "collection", "downstream-if", "true_input"))
session = GraphExecutionState(graph=graph)
queue_item = type(
@@ -698,14 +827,26 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
runner.run(queue_item=queue_item)
assert not session.is_waiting_on_workflow_call()
- assert "downstream-add" in session.executed
- assert len(events.started) == 3
+ assert "downstream-if" in session.executed
+ assert len(events.started) == 5
assert [invocation.get_type() for _queue_item, invocation in events.started] == [
"call_saved_workflow",
"add",
- "add",
+ "integer_collection",
+ "workflow_return",
+ "if",
+ ]
+ assert len(events.completed) == 5
+ parent_outputs = [
+ output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
]
- assert len(events.completed) == 3
+ downstream_outputs = [
+ output for invocation, _queue_item, output in events.completed if invocation.get_type() == "if"
+ ]
+ assert len(parent_outputs) == 1
+ assert parent_outputs[0].collection == [3]
+ assert len(downstream_outputs) == 1
+ assert downstream_outputs[0].value == [3]
assert events.errors == []
assert session_queue.completed_item_ids == [1]
assert session_queue.session_updates == [(1, session)]
@@ -727,6 +868,7 @@ def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypa
"item_id": 1,
"session_id": "session-id",
"user_id": "user-1",
+ "status": "in_progress",
"session": session,
},
)()
@@ -736,8 +878,8 @@ def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypa
assert not session.is_waiting_on_workflow_call()
assert session.waiting_workflow_call_child_session is None
assert invocation.id in session.executed
- assert len(events.started) == 2
- assert len(events.completed) == 2
+ assert len(events.started) == 4
+ assert len(events.completed) == 4
assert events.errors == []
@@ -778,10 +920,78 @@ def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch
output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
]
assert len(parent_outputs) == 1
- assert parent_outputs[0].value == 0
+ assert parent_outputs[0].collection == [3]
assert events.errors == []
+def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-return"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ child_return_outputs = [
+ output
+ for invocation, child_queue_item, output in events.completed
+ if child_queue_item.session is not session
+ and child_queue_item.session.prepared_source_mapping[invocation.id] == "child-return"
+ ]
+ parent_outputs = [
+ output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
+ ]
+
+ assert len(child_return_outputs) == 1
+ assert child_return_outputs[0].collection == [7, 8]
+ assert len(parent_outputs) == 1
+ assert parent_outputs[0].collection == [7, 8]
+ assert session_queue.completed_item_ids == [1]
+ assert events.errors == []
+
+
+def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-no-return"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ runner.run(queue_item=queue_item)
+
+ assert session.has_error()
+ assert session_queue.failed_item_ids == [1]
+ assert len(events.errors) == 1
+ assert "workflow_return" in events.errors[0][3]
+
+
def test_run_respects_child_dependency_readiness(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
From a957869634fae276e2f69879425e81354936a072 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Mon, 20 Apr 2026 18:51:08 -0500
Subject: [PATCH 045/100] Fix lazy If branch pruning and skipped-parent
handling in graph runtime
---
invokeai/app/services/shared/README.md | 11 +-
invokeai/app/services/shared/graph.py | 35 ++++--
tests/test_graph_execution_state.py | 142 ++++++++++++++++++++++++-
3 files changed, 178 insertions(+), 10 deletions(-)
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 113b7a41e54..a11b04661d8 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -123,7 +123,8 @@ mutation helpers. Those helpers reject changes once the affected nodes have alre
- `_PreparedExecRegistry` Owns the relationship between source graph nodes and prepared execution graph nodes, plus
cached metadata such as iteration path and runtime state.
- `_ExecutionMaterializer` Expands source graph nodes into concrete execution graph nodes when the scheduler runs out of
- ready work.
+ ready work. When matching prepared parents for a downstream exec node, skipped prepared exec nodes are ignored and
+ cannot be selected as live inputs.
- `_ExecutionScheduler` Owns indegree transitions, ready queues, class batching, and downstream release on completion.
- `_ExecutionRuntime` Owns iteration-path lookup and input hydration for prepared exec nodes.
- `_IfBranchScheduler` Applies lazy `If` semantics by deferring branch-local work until the condition is known, then
@@ -178,7 +179,9 @@ Run `C` -> `D:0` -> enqueue `D`. Run `D` -> done.
- For **CollectInvocation**: gather all incoming `item` values into `collection`, sorting inputs by iteration path so
collected results are stable across expanded iterations. Incoming `collection` values are merged first, then incoming
`item` values are appended.
-- For **IfInvocation**: hydrate only `condition` and the selected branch input.
+- For **IfInvocation**: hydrate only `condition` and the selected branch input. If the selected branch's upstream exec
+ node was skipped and therefore produced no runtime output, the branch input is left at its default value (typically
+ `None`) instead of raising during hydration.
- For all others: deep-copy each incoming edge's value into the destination field. This prevents cross-node mutation
through shared references.
@@ -191,7 +194,11 @@ Run `C` -> `D:0` -> enqueue `D`. Run `D` -> done.
- Once the prepared `If` node resolves its condition:
- the selected branch is released
- the unselected branch is marked skipped
+ - unselected input edges on the prepared `If` exec node are pruned from the execution graph so they no longer
+ participate in downstream indegree accounting
- branch-exclusive ancestors of the unselected branch are never executed
+- Skipped branch-local exec nodes may still be treated as executed for scheduling purposes, but they do not create
+ entries in `results`.
- Shared ancestors still execute if they are required by the selected branch or by any other live path in the graph.
This behavior is implemented in the runtime scheduler, not in the invocation body itself.
diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py
index 24c1dd1fe4f..e7f5c4bcd85 100644
--- a/invokeai/app/services/shared/graph.py
+++ b/invokeai/app/services/shared/graph.py
@@ -194,11 +194,11 @@ def _get_selected_branch_fields(self, node: IfInvocation) -> tuple[str, str]:
def _prune_unselected_if_inputs(self, exec_node_id: str, unselected_field: str) -> None:
for edge in self._state.execution_graph._get_input_edges(exec_node_id, unselected_field):
- if edge.source.node_id in self._state.executed:
- continue
- if self._state.indegree[exec_node_id] == 0:
- raise RuntimeError(f"indegree underflow for {exec_node_id} when pruning {unselected_field}")
- self._state.indegree[exec_node_id] -= 1
+ if edge.source.node_id not in self._state.executed:
+ if self._state.indegree[exec_node_id] == 0:
+ raise RuntimeError(f"indegree underflow for {exec_node_id} when pruning {unselected_field}")
+ self._state.indegree[exec_node_id] -= 1
+ self._state.execution_graph.delete_edge(edge)
def _apply_branch_resolution(
self,
@@ -424,7 +424,11 @@ def get_node_iterators(self, node_id: str, it_graph: Optional[nx.DiGraph] = None
return [n for n in nx.ancestors(g, node_id) if isinstance(self._state.graph.get_node(n), IterateInvocation)]
def _get_prepared_nodes_for_source(self, source_node_id: str) -> set[str]:
- return self._state.source_prepared_mapping[source_node_id]
+ return {
+ exec_node_id
+ for exec_node_id in self._state.source_prepared_mapping[source_node_id]
+ if self._state._get_prepared_exec_metadata(exec_node_id).state != "skipped"
+ }
def _get_parent_iterator_exec_nodes(
self, source_node_id: str, graph: nx.DiGraph, prepared_iterator_nodes: list[str]
@@ -743,6 +747,12 @@ def _sort_collect_input_edges(self, input_edges: list[Edge], field_name: str) ->
def _get_copied_result_value(self, edge: Edge) -> Any:
return copydeep(getattr(self._state.results[edge.source.node_id], edge.source.field))
+ def _try_get_copied_result_value(self, edge: Edge) -> tuple[bool, Any]:
+ source_output = self._state.results.get(edge.source.node_id)
+ if source_output is None:
+ return False, None
+ return True, copydeep(getattr(source_output, edge.source.field))
+
def _build_collect_collection(self, input_edges: list[Edge]) -> list[Any]:
item_edges = self._sort_collect_input_edges(input_edges, ITEM_FIELD)
collection_edges = self._sort_collect_input_edges(input_edges, COLLECTION_FIELD)
@@ -771,7 +781,18 @@ def _prepare_collect_inputs(self, node: "CollectInvocation", input_edges: list[E
def _prepare_if_inputs(self, node: IfInvocation, input_edges: list[Edge]) -> None:
selected_field = self._state._resolved_if_exec_branches.get(node.id)
allowed_fields = {"condition", selected_field} if selected_field is not None else {"condition"}
- self._set_node_inputs(node, input_edges, allowed_fields)
+
+ for edge in input_edges:
+ if edge.destination.field not in allowed_fields:
+ continue
+
+ found_value, copied_value = self._try_get_copied_result_value(edge)
+ if not found_value:
+ # A skipped branch-local exec node is considered executed for scheduling purposes, but it does not
+ # produce an output payload. Leave the optional branch input at its default None instead of crashing.
+ continue
+
+ setattr(node, edge.destination.field, copied_value)
def _prepare_default_inputs(self, node: BaseInvocation, input_edges: list[Edge]) -> None:
self._set_node_inputs(node, input_edges)
diff --git a/tests/test_graph_execution_state.py b/tests/test_graph_execution_state.py
index ffd0ca1559d..c2ea198bc29 100644
--- a/tests/test_graph_execution_state.py
+++ b/tests/test_graph_execution_state.py
@@ -7,7 +7,7 @@
from invokeai.app.invocations.collections import RangeInvocation
from invokeai.app.invocations.logic import IfInvocation, IfInvocationOutput
from invokeai.app.invocations.math import AddInvocation, MultiplyInvocation
-from invokeai.app.invocations.primitives import BooleanCollectionInvocation, BooleanInvocation
+from invokeai.app.invocations.primitives import BooleanCollectionInvocation, BooleanInvocation, BooleanOutput
from invokeai.app.services.shared.graph import (
CollectInvocation,
Graph,
@@ -750,6 +750,146 @@ def test_if_graph_optimized_behavior_keeps_shared_live_consumers_per_iteration()
assert executed_source_ids.count("false_branch") == 2
+def test_if_graph_optimized_behavior_handles_selected_true_branch_with_shared_false_input_ancestor():
+ graph = Graph()
+ graph.add_node(BooleanInvocation(id="condition", value=True))
+ graph.add_node(AnyTypeTestInvocation(id="shared_item", value="shared"))
+ graph.add_node(AnyTypeTestInvocation(id="true_item", value="true"))
+ graph.add_node(CollectInvocation(id="shared_collect"))
+ graph.add_node(CollectInvocation(id="true_collect"))
+ graph.add_node(IfInvocation(id="if"))
+ graph.add_node(AnyTypeTestInvocation(id="selected_output"))
+
+ graph.add_edge(create_edge("condition", "value", "if", "condition"))
+ graph.add_edge(create_edge("shared_item", "value", "shared_collect", "item"))
+ graph.add_edge(create_edge("shared_collect", "collection", "true_collect", "collection"))
+ graph.add_edge(create_edge("true_item", "value", "true_collect", "item"))
+ graph.add_edge(create_edge("shared_collect", "collection", "if", "false_input"))
+ graph.add_edge(create_edge("true_collect", "collection", "if", "true_input"))
+ graph.add_edge(create_edge("if", "value", "selected_output", "value"))
+
+ g = GraphExecutionState(graph=graph)
+ executed_source_ids = execute_all_nodes(g)
+
+ prepared_selected_output_id = next(iter(g.source_prepared_mapping["selected_output"]))
+ assert g.results[prepared_selected_output_id].value == ["shared", "true"]
+ assert set(executed_source_ids) == {
+ "condition",
+ "shared_item",
+ "true_item",
+ "shared_collect",
+ "true_collect",
+ "if",
+ "selected_output",
+ }
+
+
+def test_if_graph_optimized_behavior_handles_selected_false_branch_with_shared_true_input_ancestor():
+ graph = Graph()
+ graph.add_node(BooleanInvocation(id="condition", value=False))
+ graph.add_node(AnyTypeTestInvocation(id="shared_item", value="shared"))
+ graph.add_node(AnyTypeTestInvocation(id="true_item", value="true"))
+ graph.add_node(CollectInvocation(id="shared_collect"))
+ graph.add_node(CollectInvocation(id="true_collect"))
+ graph.add_node(IfInvocation(id="if"))
+ graph.add_node(AnyTypeTestInvocation(id="selected_output"))
+
+ graph.add_edge(create_edge("condition", "value", "if", "condition"))
+ graph.add_edge(create_edge("shared_item", "value", "shared_collect", "item"))
+ graph.add_edge(create_edge("shared_collect", "collection", "true_collect", "collection"))
+ graph.add_edge(create_edge("true_item", "value", "true_collect", "item"))
+ graph.add_edge(create_edge("shared_collect", "collection", "if", "false_input"))
+ graph.add_edge(create_edge("true_collect", "collection", "if", "true_input"))
+ graph.add_edge(create_edge("if", "value", "selected_output", "value"))
+
+ g = GraphExecutionState(graph=graph)
+ executed_source_ids = execute_all_nodes(g)
+
+ prepared_selected_output_id = next(iter(g.source_prepared_mapping["selected_output"]))
+ assert g.results[prepared_selected_output_id].value == ["shared"]
+ assert set(executed_source_ids) == {
+ "condition",
+ "shared_item",
+ "shared_collect",
+ "if",
+ "selected_output",
+ }
+ assert "true_item" not in executed_source_ids
+ assert "true_collect" not in executed_source_ids
+
+
+def test_prepare_if_inputs_ignores_selected_branch_sources_without_results():
+ graph = Graph()
+ graph.add_node(BooleanInvocation(id="condition", value=True))
+ graph.add_node(PromptTestInvocation(id="true_value", prompt="true branch"))
+ graph.add_node(IfInvocation(id="if"))
+
+ graph.add_edge(create_edge("condition", "value", "if", "condition"))
+ graph.add_edge(create_edge("true_value", "prompt", "if", "true_input"))
+
+ g = GraphExecutionState(graph=graph)
+
+ condition_exec_id = g._create_execution_node("condition", [])[0]
+ true_value_exec_id = g._create_execution_node("true_value", [])[0]
+ if_exec_id = g._create_execution_node(
+ "if",
+ [("condition", condition_exec_id), ("true_value", true_value_exec_id)],
+ )[0]
+
+ g.executed.add(condition_exec_id)
+ g.results[condition_exec_id] = BooleanOutput(value=True)
+ g.executed.add(true_value_exec_id)
+ g._resolved_if_exec_branches[if_exec_id] = "true_input"
+
+ if_node = g.execution_graph.get_node(if_exec_id)
+ g._prepare_inputs(if_node)
+
+ assert if_node.condition is True
+ assert if_node.true_input is None
+
+
+def test_get_iteration_node_ignores_skipped_prepared_exec_nodes():
+ graph = Graph()
+ graph.add_node(PromptTestInvocation(id="value", prompt="branch value"))
+
+ g = GraphExecutionState(graph=graph)
+
+ skipped_exec_id = g._create_execution_node("value", [])[0]
+ active_exec_id = g._create_execution_node("value", [])[0]
+ g._set_prepared_exec_state(skipped_exec_id, "skipped")
+
+ selected_exec_id = g._get_iteration_node("value", graph.nx_graph_flat(), g.execution_graph.nx_graph_flat(), [])
+
+ assert selected_exec_id == active_exec_id
+
+
+def test_get_iteration_node_returns_single_active_prepared_exec_node():
+ graph = Graph()
+ graph.add_node(PromptTestInvocation(id="value", prompt="branch value"))
+
+ g = GraphExecutionState(graph=graph)
+
+ active_exec_id = g._create_execution_node("value", [])[0]
+
+ selected_exec_id = g._get_iteration_node("value", graph.nx_graph_flat(), g.execution_graph.nx_graph_flat(), [])
+
+ assert selected_exec_id == active_exec_id
+
+
+def test_get_iteration_node_returns_none_when_only_skipped_prepared_exec_nodes_exist():
+ graph = Graph()
+ graph.add_node(PromptTestInvocation(id="value", prompt="branch value"))
+
+ g = GraphExecutionState(graph=graph)
+
+ skipped_exec_id = g._create_execution_node("value", [])[0]
+ g._set_prepared_exec_state(skipped_exec_id, "skipped")
+
+ selected_exec_id = g._get_iteration_node("value", graph.nx_graph_flat(), g.execution_graph.nx_graph_flat(), [])
+
+ assert selected_exec_id is None
+
+
def test_are_connection_types_compatible_accepts_subclass_to_base():
"""A subclass output should be connectable to a base-class input.
From 1102655be7b6b710edca7992a1d530d405a17580 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Mon, 20 Apr 2026 19:23:14 -0500
Subject: [PATCH 046/100] Extract workflow call coordinator
---
docs/contributing/call_saved_workflow.md | 28 ++-
.../session_processor_default.py | 219 ++++++++++++------
invokeai/app/services/shared/README.md | 8 +-
.../test_session_processor_shutdown.py | 100 ++++++--
4 files changed, 248 insertions(+), 107 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index b3fb90ba958..3e6f260d6a3 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -65,9 +65,12 @@ Implemented runtime scaffolding:
- validates and applies parent call arguments to the child graph
- creates a child `GraphExecutionState`
- attaches that child session to the waiting parent session
- - runs the child session inline in the runner
- - captures the child `workflow_return` output
- - clears the waiting state and completes the parent `call_saved_workflow` node with that returned collection
+- `WorkflowCallCoordinator` now owns the temporary parent/child orchestration path:
+ - when a session is waiting on a workflow call, the coordinator runs the attached child session as a distinct
+ execution unit
+ - when the child finishes successfully, the coordinator resumes the parent session
+ - the parent is completed with the child `workflow_return` collection during that resume path
+ - if the child fails, the coordinator fails the suspended parent `call_saved_workflow` node
- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
- Dynamic call arguments now execute end-to-end in the current runner path:
- literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node
@@ -83,7 +86,8 @@ Implemented conversion helper:
What is still not implemented:
-- the child workflow is executed inline by the parent runner, not yet as its own queue item or scheduler-visible unit
+- the child workflow is still executed through a temporary attached-session path, not yet as its own queue item or
+ scheduler-visible unit
- the child workflow is not persisted as its own queue item
- parent-child queue/session identifiers are not yet formalized beyond the attached child `GraphExecutionState`
- saved workflows containing batch-special nodes such as `image_batch` are not supported by the current child graph
@@ -93,7 +97,9 @@ Conclusion:
- the editor contract is largely in place
- the parent-side runtime call boundary is in place
-- child execution, argument forwarding, and explicit child return capture now work through the inline runner path
+- child execution, argument forwarding, and explicit child return capture now work through the temporary
+ coordinator-owned
+ attached-session path
- first-class child execution semantics are the remaining major runtime step
## Architectural Direction
@@ -271,13 +277,13 @@ Current insertion points already used:
- `DefaultSessionRunner.run_node()` detects `call_saved_workflow` and enters boundary state
- `GraphExecutionState` stores the waiting/call-stack state and attached child session
-- `DefaultSessionRunner.run_node()` currently executes the attached child session inline before completing the parent
- call node with the child `workflow_return` collection
+- `WorkflowCallCoordinator` currently runs the attached child session, then resumes the parent and completes the call
+ node with the child `workflow_return` collection
Next runtime work still needed:
-- move child execution off the temporary inline runner path and onto the intended first-class parent-child runtime
- boundary once return propagation is in place
+- move child execution off the temporary attached-session processor path and onto the intended first-class parent-child
+ runtime boundary
- persist and/or formalize parent-child identifiers beyond the in-memory attached child session
- define how the child completion or failure is delivered back to the suspended parent
- replace the current unsupported batch-special-node limitation by routing child execution through machinery that can
@@ -363,7 +369,7 @@ Already covered:
- child workflow JSON conversion to backend `Graph`
- child graph build failure does not leave the parent in a partial waiting state
- child `GraphExecutionState` is attached to the waiting parent session
-- inline child execution completes the parent queue item instead of leaving it stuck in `in_progress`
+- coordinator-owned child execution completes the parent queue item instead of leaving it stuck in `in_progress`
- literal and connected dynamic call arguments are applied to the child graph at runtime
- non-exposed dynamic call arguments are rejected at runtime
- child `workflow_return` output is captured and becomes the parent `call_saved_workflow` output
@@ -382,7 +388,7 @@ Still needed in later increments:
The next incremental step should be:
-- move the temporary inline child execution path toward the intended first-class parent-child runtime model
+- move the temporary attached-session processor path toward the intended first-class parent-child runtime model
- keep that step test-first
- preserve the current bounded nested-call runtime state while making return/resume behavior explicit
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index 45511bac4aa..2bca932efc9 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -42,6 +42,153 @@
from invokeai.app.util.profiler import Profiler
+class WorkflowCallCoordinator:
+ """Coordinates temporary parent/child workflow-call execution."""
+
+ def __init__(self, session_runner: "DefaultSessionRunner") -> None:
+ self._session_runner = session_runner
+
+ def _collect_call_saved_workflow_inputs(
+ self, invocation: CallSavedWorkflowInvocation, queue_item: SessionQueueItem
+ ) -> dict[str, Any]:
+ workflow_inputs = dict(invocation.workflow_inputs)
+ for edge in queue_item.session.execution_graph._get_input_edges(invocation.id):
+ if not is_call_saved_workflow_dynamic_input(edge.destination.field):
+ continue
+ if edge.source.node_id not in queue_item.session.results:
+ continue
+ workflow_inputs[edge.destination.field] = getattr(
+ queue_item.session.results[edge.source.node_id], edge.source.field
+ )
+ return workflow_inputs
+
+ @staticmethod
+ def build_child_queue_item(queue_item: SessionQueueItem, child_session: GraphExecutionState) -> SessionQueueItem:
+ if hasattr(queue_item, "model_copy"):
+ return queue_item.model_copy(update={"session": child_session, "session_id": child_session.id})
+
+ child_queue_item = type(queue_item).__new__(type(queue_item))
+ child_queue_item.__dict__ = {**queue_item.__dict__, "session": child_session, "session_id": child_session.id}
+ return child_queue_item
+
+ @staticmethod
+ def get_waiting_workflow_call_invocation(queue_item: SessionQueueItem) -> CallSavedWorkflowInvocation:
+ waiting_frame = queue_item.session.waiting_workflow_call
+ if waiting_frame is None:
+ raise ValueError("Execution state is not waiting on a workflow call.")
+ invocation = queue_item.session.execution_graph.nodes.get(waiting_frame.prepared_call_node_id)
+ if not isinstance(invocation, CallSavedWorkflowInvocation):
+ raise ValueError("Waiting workflow call frame does not point to a call_saved_workflow invocation.")
+ return invocation
+
+ @staticmethod
+ def get_child_workflow_return_output(child_session: GraphExecutionState) -> WorkflowReturnOutput:
+ workflow_return_node_ids = [
+ node_id for node_id, node in child_session.graph.nodes.items() if node.get_type() == "workflow_return"
+ ]
+ if not workflow_return_node_ids:
+ raise ValueError("The selected saved workflow must contain exactly one workflow_return node.")
+ if len(workflow_return_node_ids) > 1:
+ raise ValueError("The selected saved workflow must not contain more than one workflow_return node.")
+
+ workflow_return_node_id = workflow_return_node_ids[0]
+ prepared_return_node_ids = child_session.source_prepared_mapping.get(workflow_return_node_id, set())
+ if len(prepared_return_node_ids) != 1:
+ raise ValueError(
+ "The selected saved workflow produced an unsupported number of workflow_return executions."
+ )
+
+ prepared_return_node_id = next(iter(prepared_return_node_ids))
+ output = child_session.results.get(prepared_return_node_id)
+ if not isinstance(output, WorkflowReturnOutput):
+ raise ValueError("The selected saved workflow did not produce a valid workflow_return output.")
+
+ return output
+
+ def begin_workflow_call_boundary(
+ self,
+ invocation: CallSavedWorkflowInvocation,
+ queue_item: SessionQueueItem,
+ workflow_record,
+ ) -> None:
+ call_frame = queue_item.session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
+ child_graph = build_graph_from_workflow(workflow_record.workflow.model_dump())
+ apply_workflow_inputs_to_graph(
+ child_graph,
+ workflow_record.workflow.model_dump(),
+ self._collect_call_saved_workflow_inputs(invocation, queue_item),
+ )
+ child_session = queue_item.session.create_child_workflow_execution_state(child_graph, call_frame)
+ queue_item.session.begin_waiting_on_workflow_call(call_frame)
+ queue_item.session.attach_waiting_workflow_call_child_session(child_session)
+
+ def resume_waiting_workflow_call(self, queue_item: SessionQueueItem) -> None:
+ invocation = self.get_waiting_workflow_call_invocation(queue_item)
+ child_session = queue_item.session.waiting_workflow_call_child_session
+ if child_session is None:
+ raise ValueError("Execution state is waiting on a workflow call but has no attached child session.")
+ output = self.get_child_workflow_return_output(child_session)
+ queue_item.session.end_waiting_on_workflow_call()
+ queue_item.session.complete(invocation.id, output)
+ self._session_runner._on_after_run_node(invocation, queue_item, output)
+
+ def fail_waiting_workflow_call(self, queue_item: SessionQueueItem, error_message: str) -> None:
+ invocation = self.get_waiting_workflow_call_invocation(queue_item)
+ self._session_runner._on_node_error(
+ invocation=invocation,
+ queue_item=queue_item,
+ error_type="ValueError",
+ error_message=error_message,
+ error_traceback=error_message,
+ )
+
+ def run_queue_item(self, queue_item: SessionQueueItem) -> None:
+ self._session_runner._on_before_run_session(queue_item=queue_item)
+
+ active_queue_item = queue_item
+ parent_queue_items: list[SessionQueueItem] = []
+
+ while True:
+ self._session_runner._run_session_loop(active_queue_item)
+
+ if active_queue_item.session.has_error():
+ if not parent_queue_items:
+ break
+ parent_queue_item = parent_queue_items.pop()
+ waiting_frame = parent_queue_item.session.waiting_workflow_call
+ if waiting_frame is None:
+ raise ValueError("Parent queue item is missing workflow call waiting state.")
+ self.fail_waiting_workflow_call(
+ parent_queue_item,
+ f"The selected saved workflow '{waiting_frame.workflow_id}' failed during child execution.",
+ )
+ active_queue_item = parent_queue_item
+ break
+
+ if active_queue_item.session.is_waiting_on_workflow_call():
+ child_session = active_queue_item.session.waiting_workflow_call_child_session
+ if child_session is None:
+ raise ValueError("Execution state is waiting on a workflow call but has no attached child session.")
+ parent_queue_items.append(active_queue_item)
+ active_queue_item = self.build_child_queue_item(active_queue_item, child_session)
+ continue
+
+ if parent_queue_items:
+ parent_queue_item = parent_queue_items.pop()
+ try:
+ self.resume_waiting_workflow_call(parent_queue_item)
+ except Exception as e:
+ self.fail_waiting_workflow_call(parent_queue_item, str(e))
+ active_queue_item = parent_queue_item
+ break
+ active_queue_item = parent_queue_item
+ continue
+
+ break
+
+ self._session_runner._on_after_run_session(queue_item=queue_item)
+
+
class DefaultSessionRunner(SessionRunnerBase):
"""Processes a single session's invocations."""
@@ -67,6 +214,7 @@ def __init__(
self._on_after_run_node_callbacks = on_after_run_node_callbacks or []
self._on_node_error_callbacks = on_node_error_callbacks or []
self._on_after_run_session_callbacks = on_after_run_session_callbacks or []
+ self.workflow_call_coordinator = WorkflowCallCoordinator(self)
def start(self, services: InvocationServices, cancel_event: ThreadEvent, profiler: Optional[Profiler] = None):
self._services = services
@@ -112,53 +260,6 @@ def _run_session_loop(self, queue_item: SessionQueueItem) -> None:
):
break
- def _collect_call_saved_workflow_inputs(
- self, invocation: CallSavedWorkflowInvocation, queue_item: SessionQueueItem
- ) -> dict[str, Any]:
- workflow_inputs = dict(invocation.workflow_inputs)
- for edge in queue_item.session.execution_graph._get_input_edges(invocation.id):
- if not is_call_saved_workflow_dynamic_input(edge.destination.field):
- continue
- if edge.source.node_id not in queue_item.session.results:
- continue
- workflow_inputs[edge.destination.field] = getattr(
- queue_item.session.results[edge.source.node_id], edge.source.field
- )
- return workflow_inputs
-
- @staticmethod
- def _build_child_queue_item(queue_item: SessionQueueItem, child_session) -> SessionQueueItem:
- if hasattr(queue_item, "model_copy"):
- return queue_item.model_copy(update={"session": child_session, "session_id": child_session.id})
-
- child_queue_item = type(queue_item).__new__(type(queue_item))
- child_queue_item.__dict__ = {**queue_item.__dict__, "session": child_session, "session_id": child_session.id}
- return child_queue_item
-
- @staticmethod
- def _get_child_workflow_return_output(child_session: GraphExecutionState) -> WorkflowReturnOutput:
- workflow_return_node_ids = [
- node_id for node_id, node in child_session.graph.nodes.items() if node.get_type() == "workflow_return"
- ]
- if not workflow_return_node_ids:
- raise ValueError("The selected saved workflow must contain exactly one workflow_return node.")
- if len(workflow_return_node_ids) > 1:
- raise ValueError("The selected saved workflow must not contain more than one workflow_return node.")
-
- workflow_return_node_id = workflow_return_node_ids[0]
- prepared_return_node_ids = child_session.source_prepared_mapping.get(workflow_return_node_id, set())
- if len(prepared_return_node_ids) != 1:
- raise ValueError(
- "The selected saved workflow produced an unsupported number of workflow_return executions."
- )
-
- prepared_return_node_id = next(iter(prepared_return_node_ids))
- output = child_session.results.get(prepared_return_node_id)
- if not isinstance(output, WorkflowReturnOutput):
- raise ValueError("The selected saved workflow did not produce a valid workflow_return output.")
-
- return output
-
def run(self, queue_item: SessionQueueItem):
# Exceptions raised outside `run_node` are handled by the processor. There is no need to catch them here.
@@ -185,27 +286,7 @@ def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem):
if isinstance(invocation, CallSavedWorkflowInvocation):
workflow_record = invocation.validate_selected_workflow(context)
- call_frame = queue_item.session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
- child_graph = build_graph_from_workflow(workflow_record.workflow.model_dump())
- apply_workflow_inputs_to_graph(
- child_graph,
- workflow_record.workflow.model_dump(),
- self._collect_call_saved_workflow_inputs(invocation, queue_item),
- )
- child_session = queue_item.session.create_child_workflow_execution_state(child_graph, call_frame)
- queue_item.session.begin_waiting_on_workflow_call(call_frame)
- queue_item.session.attach_waiting_workflow_call_child_session(child_session)
- child_queue_item = self._build_child_queue_item(queue_item, child_session)
- self._run_session_loop(child_queue_item)
- if child_session.has_error():
- raise ValueError(
- f"The selected saved workflow '{invocation.workflow_id}' failed during child execution."
- )
-
- output = self._get_child_workflow_return_output(child_session)
- queue_item.session.end_waiting_on_workflow_call()
- queue_item.session.complete(invocation.id, output)
- self._on_after_run_node(invocation, queue_item, output)
+ self.workflow_call_coordinator.begin_workflow_call_boundary(invocation, queue_item, workflow_record)
return
# Invoke the node
@@ -540,7 +621,7 @@ def _process(
cancel_event.clear()
# Run the graph
- self.session_runner.run(queue_item=self._queue_item)
+ self.workflow_call_coordinator.run_queue_item(self._queue_item)
except Exception as e:
error_type = e.__class__.__name__
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index edf767e2297..7440326cad7 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -135,11 +135,11 @@ Workflow-call note:
- `GraphExecutionState` can represent a paused parent execution plus an attached child execution state, but it does not
itself orchestrate child execution.
-- In the current temporary implementation, `DefaultSessionRunner.run_node()` runs the attached child session inline,
- captures the child `workflow_return` output, then clears the waiting state and completes the parent
- `call_saved_workflow` node with that returned collection.
+- In the current temporary implementation, `DefaultSessionRunner.run_node()` establishes the workflow call boundary and
+ attaches the child execution state, while `WorkflowCallCoordinator` runs that attached child session, resumes the
+ parent, and completes the parent `call_saved_workflow` node with the child `workflow_return` output.
- This is a tactical step to keep the feature testable and should eventually be replaced by a first-class parent/child
- execution mechanism with explicit return propagation.
+ execution mechanism with explicit queue-visible child lifecycle and return propagation.
### 4.3 Runtime helper classes
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index 7f9a77f03f3..3a6937ead4a 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -9,8 +9,11 @@
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
from invokeai.app.invocations.logic import IfInvocation
from invokeai.app.invocations.math import AddInvocation
-from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
-from invokeai.app.services.session_processor.session_processor_default import DefaultSessionRunner
+from invokeai.app.services.session_processor.session_processor_default import (
+ DefaultSessionProcessor,
+ DefaultSessionRunner,
+ WorkflowCallCoordinator,
+)
from invokeai.app.services.shared.graph import Graph, GraphExecutionState, WorkflowCallFrame
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory
from tests.dangerously_run_function_in_subprocess import dangerously_run_function_in_subprocess
@@ -695,7 +698,7 @@ def test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch:
assert session_queue.completed_item_ids == []
-def test_run_node_executes_child_workflow_and_clears_waiting_state(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_run_node_enters_waiting_state_without_executing_child_inline(monkeypatch: pytest.MonkeyPatch) -> None:
runner, events, _workflow_records = _build_workflow_runner(monkeypatch)
invocation = CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a")
session = _WorkflowCallBoundarySession(invocation.id)
@@ -720,14 +723,13 @@ def test_run_node_executes_child_workflow_and_clears_waiting_state(monkeypatch:
runner.run_node(invocation=invocation, queue_item=queue_item)
assert len(session.frames) == 1
- assert session.waiting is None
+ assert session.waiting == session.frames[0]
assert session.frames[0].prepared_call_node_id == invocation.id
assert session.frames[0].workflow_id == "workflow-a"
- assert len(session.completed) == 1
- assert isinstance(session.completed[0][1], WorkflowReturnOutput)
- assert session.completed[0][1].collection == [3]
- assert len(events.started) == 4
- assert len(events.completed) == 4
+ assert session.waiting_workflow_call_child_session is not None
+ assert session.completed == []
+ assert len(events.started) == 1
+ assert events.completed == []
assert events.errors == []
@@ -800,11 +802,55 @@ def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch:
assert session_queue.completed_item_ids == []
+def test_workflow_call_coordinator_runs_child_session_and_resumes_parent_workflow_call(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ coordinator = WorkflowCallCoordinator(runner)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ graph.add_node(IfInvocation(id="downstream-if", condition=True, false_input=0))
+ graph.add_edge(create_edge("call-node", "collection", "downstream-if", "true_input"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ },
+ )()
+
+ coordinator.run_queue_item(queue_item)
+
+ assert not session.is_waiting_on_workflow_call()
+ assert "downstream-if" in session.executed
+ parent_outputs = [
+ output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
+ ]
+ downstream_outputs = [
+ output for invocation, _queue_item, output in events.completed if invocation.get_type() == "if"
+ ]
+ assert len(parent_outputs) == 1
+ assert parent_outputs[0].collection == [3]
+ assert len(downstream_outputs) == 1
+ assert downstream_outputs[0].value == [3]
+ assert session_queue.completed_item_ids == [1]
+ assert events.errors == []
+
+
def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
@@ -824,7 +870,7 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
assert not session.is_waiting_on_workflow_call()
assert "downstream-if" in session.executed
@@ -875,17 +921,18 @@ def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypa
runner.run_node(invocation=invocation, queue_item=queue_item)
- assert not session.is_waiting_on_workflow_call()
- assert session.waiting_workflow_call_child_session is None
- assert invocation.id in session.executed
- assert len(events.started) == 4
- assert len(events.completed) == 4
+ assert session.is_waiting_on_workflow_call()
+ assert session.waiting_workflow_call_child_session is not None
+ assert invocation.id not in session.executed
+ assert len(events.started) == 1
+ assert events.completed == []
assert events.errors == []
def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
@@ -903,7 +950,7 @@ def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
assert not session.is_waiting_on_workflow_call()
assert session_queue.completed_item_ids == [1]
@@ -927,6 +974,7 @@ def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch
def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-return"))
@@ -944,7 +992,7 @@ def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypa
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
child_return_outputs = [
output
@@ -967,6 +1015,7 @@ def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypa
def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-no-return"))
@@ -984,7 +1033,7 @@ def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeyp
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
assert session.has_error()
assert session_queue.failed_item_ids == [1]
@@ -995,6 +1044,7 @@ def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeyp
def test_run_respects_child_dependency_readiness(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-dependent"))
@@ -1012,7 +1062,7 @@ def test_run_respects_child_dependency_readiness(monkeypatch: pytest.MonkeyPatch
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
child_completions = [
(child_queue_item.session.prepared_source_mapping[invocation.id], output)
@@ -1029,6 +1079,7 @@ def test_run_respects_child_dependency_readiness(monkeypatch: pytest.MonkeyPatch
def test_run_respects_child_if_branching(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-if"))
@@ -1046,7 +1097,7 @@ def test_run_respects_child_if_branching(monkeypatch: pytest.MonkeyPatch) -> Non
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
child_if_outputs = [
output
@@ -1062,6 +1113,7 @@ def test_run_respects_child_if_branching(monkeypatch: pytest.MonkeyPatch) -> Non
def test_run_supports_nested_call_saved_workflow_execution(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-nested"))
@@ -1079,7 +1131,7 @@ def test_run_supports_nested_call_saved_workflow_execution(monkeypatch: pytest.M
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
call_started = [
queue_item.session.prepared_source_mapping[invocation.id]
@@ -1117,6 +1169,7 @@ def test_run_supports_nested_call_saved_workflow_execution(monkeypatch: pytest.M
def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(
@@ -1140,7 +1193,7 @@ def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypa
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
child_add_outputs = [
output
@@ -1157,6 +1210,7 @@ def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypa
def test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
graph = Graph()
graph.add_node(AddInvocation(id="source-add", a=2, b=3))
@@ -1176,7 +1230,7 @@ def test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkey
},
)()
- runner.run(queue_item=queue_item)
+ processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
child_add_outputs = [
output
From 2ca78ad2fe6a1283bec8ad35dfdc24820e70f270 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Mon, 20 Apr 2026 19:30:00 -0500
Subject: [PATCH 047/100] Track workflow call lifecycle state
---
docs/contributing/call_saved_workflow.md | 7 ++
.../session_processor_default.py | 3 +-
invokeai/app/services/shared/README.md | 3 +
invokeai/app/services/shared/graph.py | 70 ++++++++++++++++++-
.../test_session_processor_shutdown.py | 6 ++
tests/test_graph_execution_state.py | 40 +++++++++++
6 files changed, 127 insertions(+), 2 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 3e6f260d6a3..ba0493917cc 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -50,10 +50,17 @@ Implemented runtime scaffolding:
- `GraphExecutionState` now persists workflow-call runtime state:
- `workflow_call_stack`
+ - `workflow_call_history`
+ - `workflow_call_parent`
- `waiting_workflow_call`
+ - `waiting_workflow_call_execution`
- `waiting_workflow_call_child_session`
- `max_workflow_call_depth`
- Nested and recursive calls are represented by the stack, with a runtime depth cap of 4.
+- Parent/child workflow-call identity is now explicit in runtime state:
+ - the parent tracks an active `WorkflowCallExecution` record while waiting
+ - completed and failed calls are preserved in `workflow_call_history`
+ - child sessions carry a `workflow_call_parent` reference back to the parent call relationship
- `GraphExecutionState.next()` returns no runnable node while the parent session is waiting on a child workflow call.
- `GraphExecutionState.is_complete()` stays false while waiting.
- `DefaultSessionRunner.run_node()` now treats `call_saved_workflow` as a call boundary instead of a normal executable
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index 2bca932efc9..ef609eb091e 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -128,12 +128,13 @@ def resume_waiting_workflow_call(self, queue_item: SessionQueueItem) -> None:
if child_session is None:
raise ValueError("Execution state is waiting on a workflow call but has no attached child session.")
output = self.get_child_workflow_return_output(child_session)
- queue_item.session.end_waiting_on_workflow_call()
+ queue_item.session.end_waiting_on_workflow_call(status="completed")
queue_item.session.complete(invocation.id, output)
self._session_runner._on_after_run_node(invocation, queue_item, output)
def fail_waiting_workflow_call(self, queue_item: SessionQueueItem, error_message: str) -> None:
invocation = self.get_waiting_workflow_call_invocation(queue_item)
+ queue_item.session.end_waiting_on_workflow_call(status="failed", error_message=error_message)
self._session_runner._on_node_error(
invocation=invocation,
queue_item=queue_item,
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 7440326cad7..a13a4bb916c 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -113,7 +113,10 @@ mutation helpers. Those helpers reject changes once the affected nodes have alre
- `indegree: dict[str, int]` - unmet inputs per exec node.
- Workflow-call runtime state:
- `workflow_call_stack` - active parent call frames.
+ - `workflow_call_history` - completed or failed workflow-call relationships observed by this execution state.
+ - `workflow_call_parent` - parent workflow-call relationship metadata when this execution state is a child session.
- `waiting_workflow_call` - the call frame currently suspending this execution state, if any.
+ - `waiting_workflow_call_execution` - the active parent/child workflow-call relationship record for the waiting call.
- `waiting_workflow_call_child_session` - attached child execution state for the waiting workflow call, if any.
- `max_workflow_call_depth` - runtime guardrail for nested or recursive workflow calls.
- Prepared exec metadata caches:
diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py
index 85dc2c2744c..0ca5c8fe391 100644
--- a/invokeai/app/services/shared/graph.py
+++ b/invokeai/app/services/shared/graph.py
@@ -70,6 +70,7 @@ def __str__(self):
PreparedExecState = Literal["pending", "ready", "executed", "skipped"]
+WorkflowCallStatus = Literal["waiting_for_child", "running_child", "completed", "failed"]
class WorkflowCallFrame(BaseModel):
@@ -81,6 +82,31 @@ class WorkflowCallFrame(BaseModel):
depth: int = Field(description="The 1-based depth of this call frame.", ge=1)
+class WorkflowCallExecution(BaseModel):
+ """Tracks one parent/child workflow-call relationship and its lifecycle."""
+
+ id: str = Field(description="The workflow-call execution id.", default_factory=uuid_string)
+ parent_session_id: str = Field(description="The parent graph execution state id.")
+ child_session_id: Optional[str] = Field(default=None, description="The child graph execution state id, if any.")
+ prepared_call_node_id: str = Field(description="The prepared exec node id for the parent call site.")
+ source_call_node_id: str = Field(description="The source graph node id for the parent call site.")
+ workflow_id: str = Field(description="The saved workflow being called.")
+ depth: int = Field(description="The 1-based depth of this call frame.", ge=1)
+ status: WorkflowCallStatus = Field(description="The current workflow-call lifecycle state.")
+ error_message: Optional[str] = Field(default=None, description="Failure reason, if the call failed.")
+
+
+class WorkflowCallParentRef(BaseModel):
+ """Reference from a child execution state back to its parent workflow-call relationship."""
+
+ workflow_call_id: str = Field(description="The workflow-call execution id.")
+ parent_session_id: str = Field(description="The parent graph execution state id.")
+ prepared_call_node_id: str = Field(description="The prepared exec node id for the parent call site.")
+ source_call_node_id: str = Field(description="The source graph node id for the parent call site.")
+ workflow_id: str = Field(description="The saved workflow being called.")
+ depth: int = Field(description="The 1-based depth of this call frame.", ge=1)
+
+
@dataclass
class _PreparedExecNodeMetadata:
"""Cached metadata for a materialized execution node."""
@@ -1748,10 +1774,22 @@ class GraphExecutionState(BaseModel):
description="The nested workflow call stack inherited by this execution state.",
default_factory=list,
)
+ workflow_call_history: list[WorkflowCallExecution] = Field(
+ description="Completed or failed workflow-call relationships observed by this execution state.",
+ default_factory=list,
+ )
+ workflow_call_parent: Optional[WorkflowCallParentRef] = Field(
+ default=None,
+ description="Parent workflow-call relationship metadata when this execution state is a child workflow session.",
+ )
waiting_workflow_call: Optional[WorkflowCallFrame] = Field(
default=None,
description="The child workflow call this execution state is currently waiting on, if any.",
)
+ waiting_workflow_call_execution: Optional[WorkflowCallExecution] = Field(
+ default=None,
+ description="The active workflow-call relationship metadata for the current waiting child workflow, if any.",
+ )
waiting_workflow_call_child_session: Optional["GraphExecutionState"] = Field(
default=None,
description="The child workflow execution state spawned by the current waiting workflow call, if any.",
@@ -1878,6 +1916,7 @@ def _prepare_until_node_ready(self) -> Optional[BaseInvocation]:
"results",
"errors",
"workflow_call_stack",
+ "workflow_call_history",
"prepared_source_mapping",
"source_prepared_mapping",
]
@@ -1962,14 +2001,43 @@ def begin_waiting_on_workflow_call(self, frame: WorkflowCallFrame) -> None:
if self.waiting_workflow_call is not None:
raise ValueError("Execution state is already waiting on a workflow call")
self.waiting_workflow_call = frame
+ self.waiting_workflow_call_execution = WorkflowCallExecution(
+ parent_session_id=self.id,
+ prepared_call_node_id=frame.prepared_call_node_id,
+ source_call_node_id=frame.source_call_node_id,
+ workflow_id=frame.workflow_id,
+ depth=frame.depth,
+ status="waiting_for_child",
+ )
def attach_waiting_workflow_call_child_session(self, child_session: "GraphExecutionState") -> None:
if self.waiting_workflow_call is None:
raise ValueError("Execution state must be waiting on a workflow call before attaching a child session")
+ if self.waiting_workflow_call_execution is None:
+ raise ValueError("Execution state is waiting on a workflow call but has no workflow call execution")
self.waiting_workflow_call_child_session = child_session
+ self.waiting_workflow_call_execution.child_session_id = child_session.id
+ self.waiting_workflow_call_execution.status = "running_child"
+ child_session.workflow_call_parent = WorkflowCallParentRef(
+ workflow_call_id=self.waiting_workflow_call_execution.id,
+ parent_session_id=self.waiting_workflow_call_execution.parent_session_id,
+ prepared_call_node_id=self.waiting_workflow_call_execution.prepared_call_node_id,
+ source_call_node_id=self.waiting_workflow_call_execution.source_call_node_id,
+ workflow_id=self.waiting_workflow_call_execution.workflow_id,
+ depth=self.waiting_workflow_call_execution.depth,
+ )
- def end_waiting_on_workflow_call(self) -> None:
+ def end_waiting_on_workflow_call(
+ self,
+ status: Literal["completed", "failed"] = "completed",
+ error_message: Optional[str] = None,
+ ) -> None:
+ if self.waiting_workflow_call_execution is not None:
+ self.waiting_workflow_call_execution.status = status
+ self.waiting_workflow_call_execution.error_message = error_message
+ self.workflow_call_history.append(self.waiting_workflow_call_execution.model_copy(deep=True))
self.waiting_workflow_call = None
+ self.waiting_workflow_call_execution = None
self.waiting_workflow_call_child_session = None
def create_child_workflow_execution_state(self, graph: Graph, frame: WorkflowCallFrame) -> "GraphExecutionState":
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index 3a6937ead4a..1f09f0361dc 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -841,6 +841,9 @@ def test_workflow_call_coordinator_runs_child_session_and_resumes_parent_workflo
assert parent_outputs[0].collection == [3]
assert len(downstream_outputs) == 1
assert downstream_outputs[0].value == [3]
+ assert len(session.workflow_call_history) == 1
+ assert session.workflow_call_history[0].status == "completed"
+ assert session.workflow_call_history[0].child_session_id is not None
assert session_queue.completed_item_ids == [1]
assert events.errors == []
@@ -1036,6 +1039,9 @@ def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeyp
processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
assert session.has_error()
+ assert len(session.workflow_call_history) == 1
+ assert session.workflow_call_history[0].status == "failed"
+ assert session.workflow_call_history[0].error_message is not None
assert session_queue.failed_item_ids == [1]
assert len(events.errors) == 1
assert "workflow_return" in events.errors[0][3]
diff --git a/tests/test_graph_execution_state.py b/tests/test_graph_execution_state.py
index c2ef8406558..348fab2b631 100644
--- a/tests/test_graph_execution_state.py
+++ b/tests/test_graph_execution_state.py
@@ -237,6 +237,46 @@ def test_graph_child_workflow_execution_state_inherits_stack_and_isolates_runtim
assert child_state.executed == set()
+def test_graph_waiting_workflow_call_tracks_parent_child_metadata():
+ parent = GraphExecutionState(graph=Graph())
+ parent.execution_graph.add_node(PromptTestInvocation(id="prepared-parent", prompt="a"))
+ parent.prepared_source_mapping["prepared-parent"] = "source-parent"
+ frame = parent.build_workflow_call_frame(exec_node_id="prepared-parent", workflow_id="workflow-a")
+
+ child = parent.create_child_workflow_execution_state(graph=Graph(), frame=frame)
+ parent.begin_waiting_on_workflow_call(frame)
+ parent.attach_waiting_workflow_call_child_session(child)
+
+ assert parent.waiting_workflow_call_execution is not None
+ assert parent.waiting_workflow_call_execution.parent_session_id == parent.id
+ assert parent.waiting_workflow_call_execution.child_session_id == child.id
+ assert parent.waiting_workflow_call_execution.status == "running_child"
+ assert child.workflow_call_parent is not None
+ assert child.workflow_call_parent.workflow_call_id == parent.waiting_workflow_call_execution.id
+ assert child.workflow_call_parent.parent_session_id == parent.id
+
+
+def test_graph_end_waiting_on_workflow_call_records_lifecycle_history():
+ parent = GraphExecutionState(graph=Graph())
+ parent.execution_graph.add_node(PromptTestInvocation(id="prepared-parent", prompt="a"))
+ parent.prepared_source_mapping["prepared-parent"] = "source-parent"
+ frame = parent.build_workflow_call_frame(exec_node_id="prepared-parent", workflow_id="workflow-a")
+
+ child = parent.create_child_workflow_execution_state(graph=Graph(), frame=frame)
+ parent.begin_waiting_on_workflow_call(frame)
+ parent.attach_waiting_workflow_call_child_session(child)
+ parent.end_waiting_on_workflow_call(status="failed", error_message="child failed")
+
+ assert parent.waiting_workflow_call is None
+ assert parent.waiting_workflow_call_execution is None
+ assert parent.waiting_workflow_call_child_session is None
+ assert len(parent.workflow_call_history) == 1
+ assert parent.workflow_call_history[0].status == "failed"
+ assert parent.workflow_call_history[0].error_message == "child failed"
+ assert parent.workflow_call_history[0].parent_session_id == parent.id
+ assert parent.workflow_call_history[0].child_session_id == child.id
+
+
def test_graph_execution_state_serializes_recursive_workflow_call_stack():
g = GraphExecutionState(
graph=Graph(),
From 4e6a8fb620652c3ea0df10c4b427aaabb3ff8f20 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Mon, 20 Apr 2026 19:50:16 -0500
Subject: [PATCH 048/100] Add workflow call queue item metadata
---
docs/contributing/call_saved_workflow.md | 10 ++++
.../session_processor_default.py | 17 +++++-
.../session_queue/session_queue_common.py | 15 +++++
invokeai/app/services/shared/README.md | 3 +
.../test_session_processor_shutdown.py | 58 +++++++++++++++++++
5 files changed, 101 insertions(+), 2 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index ba0493917cc..accba97be4e 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -78,6 +78,13 @@ Implemented runtime scaffolding:
- when the child finishes successfully, the coordinator resumes the parent session
- the parent is completed with the child `workflow_return` collection during that resume path
- if the child fails, the coordinator fails the suspended parent `call_saved_workflow` node
+- Child `SessionQueueItem` wrappers now carry explicit relationship metadata during temporary attached-session execution:
+ - `workflow_call_id`
+ - `parent_item_id`
+ - `parent_session_id`
+ - `root_item_id`
+ - `workflow_call_depth`
+ - this metadata is used in runtime events now and should survive the later move to durable child queue rows
- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
- Dynamic call arguments now execute end-to-end in the current runner path:
- literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node
@@ -286,6 +293,8 @@ Current insertion points already used:
- `GraphExecutionState` stores the waiting/call-stack state and attached child session
- `WorkflowCallCoordinator` currently runs the attached child session, then resumes the parent and completes the call
node with the child `workflow_return` collection
+- child queue-item wrappers already carry stable parent/child identifiers even though child executions are not yet
+ persisted as their own queue rows
Next runtime work still needed:
@@ -381,6 +390,7 @@ Already covered:
- non-exposed dynamic call arguments are rejected at runtime
- child `workflow_return` output is captured and becomes the parent `call_saved_workflow` output
- child workflows without a `workflow_return` node fail cleanly when called
+- child execution events now include stable workflow-call relationship metadata on the child `SessionQueueItem`
Still needed in later increments:
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index ef609eb091e..3680163948b 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -64,11 +64,24 @@ def _collect_call_saved_workflow_inputs(
@staticmethod
def build_child_queue_item(queue_item: SessionQueueItem, child_session: GraphExecutionState) -> SessionQueueItem:
+ workflow_call_execution = queue_item.session.waiting_workflow_call_execution
+ if workflow_call_execution is None:
+ raise ValueError("Parent queue item is missing active workflow call execution metadata.")
+ root_item_id = getattr(queue_item, "root_item_id", None) or queue_item.item_id
+ child_updates = {
+ "session": child_session,
+ "session_id": child_session.id,
+ "workflow_call_id": workflow_call_execution.id,
+ "parent_item_id": queue_item.item_id,
+ "parent_session_id": queue_item.session_id,
+ "root_item_id": root_item_id,
+ "workflow_call_depth": workflow_call_execution.depth,
+ }
if hasattr(queue_item, "model_copy"):
- return queue_item.model_copy(update={"session": child_session, "session_id": child_session.id})
+ return queue_item.model_copy(update=child_updates)
child_queue_item = type(queue_item).__new__(type(queue_item))
- child_queue_item.__dict__ = {**queue_item.__dict__, "session": child_session, "session_id": child_session.id}
+ child_queue_item.__dict__ = {**queue_item.__dict__, **child_updates}
return child_queue_item
@staticmethod
diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py
index 09820fe6217..e63365160f3 100644
--- a/invokeai/app/services/session_queue/session_queue_common.py
+++ b/invokeai/app/services/session_queue/session_queue_common.py
@@ -257,6 +257,21 @@ class SessionQueueItem(BaseModel):
retried_from_item_id: Optional[int] = Field(
default=None, description="The item_id of the queue item that this item was retried from"
)
+ workflow_call_id: Optional[str] = Field(
+ default=None, description="The active workflow-call relationship id when this queue item is a child execution."
+ )
+ parent_item_id: Optional[int] = Field(
+ default=None, description="The parent queue item id when this queue item is a child workflow execution."
+ )
+ parent_session_id: Optional[str] = Field(
+ default=None, description="The parent session id when this queue item is a child workflow execution."
+ )
+ root_item_id: Optional[int] = Field(
+ default=None, description="The root queue item id for this workflow call chain, if any."
+ )
+ workflow_call_depth: Optional[int] = Field(
+ default=None, description="The 1-based workflow-call depth for this queue item when it is a child execution."
+ )
session: GraphExecutionState = Field(description="The fully-populated session to be executed")
workflow: Optional[WorkflowWithoutID] = Field(
default=None, description="The workflow associated with this queue item"
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 47aa6280244..e2641801def 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -141,6 +141,9 @@ Workflow-call note:
- In the current temporary implementation, `DefaultSessionRunner.run_node()` establishes the workflow call boundary and
attaches the child execution state, while `WorkflowCallCoordinator` runs that attached child session, resumes the
parent, and completes the parent `call_saved_workflow` node with the child `workflow_return` output.
+- Child `SessionQueueItem` wrappers created by the coordinator now carry explicit relationship metadata such as
+ `workflow_call_id`, `parent_item_id`, `parent_session_id`, `root_item_id`, and `workflow_call_depth`, even though
+ child executions are not yet persisted as separate queue rows.
- This is a tactical step to keep the feature testable and should eventually be replaced by a first-class parent/child
execution mechanism with explicit queue-visible child lifecycle and return propagation.
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index 1f09f0361dc..daeb58c36f6 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -841,6 +841,19 @@ def test_workflow_call_coordinator_runs_child_session_and_resumes_parent_workflo
assert parent_outputs[0].collection == [3]
assert len(downstream_outputs) == 1
assert downstream_outputs[0].value == [3]
+ child_started_queue_items = [
+ child_queue_item
+ for child_queue_item, invocation in events.started
+ if invocation.get_type() != "call_saved_workflow" and child_queue_item.session_id != queue_item.session_id
+ ]
+ assert len(child_started_queue_items) > 0
+ assert all(child_queue_item.workflow_call_id is not None for child_queue_item in child_started_queue_items)
+ assert all(child_queue_item.parent_item_id == queue_item.item_id for child_queue_item in child_started_queue_items)
+ assert all(
+ child_queue_item.parent_session_id == queue_item.session_id for child_queue_item in child_started_queue_items
+ )
+ assert all(child_queue_item.root_item_id == queue_item.item_id for child_queue_item in child_started_queue_items)
+ assert all(child_queue_item.workflow_call_depth == 1 for child_queue_item in child_started_queue_items)
assert len(session.workflow_call_history) == 1
assert session.workflow_call_history[0].status == "completed"
assert session.workflow_call_history[0].child_session_id is not None
@@ -848,6 +861,51 @@ def test_workflow_call_coordinator_runs_child_session_and_resumes_parent_workflo
assert events.errors == []
+def test_workflow_call_coordinator_builds_child_queue_item_with_relationship_metadata(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session_queue = _DummySessionQueue()
+ runner, _events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ coordinator = WorkflowCallCoordinator(runner)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ parent_session = GraphExecutionState(graph=graph)
+ invocation = parent_session.next()
+ assert isinstance(invocation, CallSavedWorkflowInvocation)
+
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 42,
+ "status": "in_progress",
+ "session": parent_session,
+ "session_id": parent_session.id,
+ "user_id": "user-1",
+ "workflow_call_id": None,
+ "parent_item_id": None,
+ "parent_session_id": None,
+ "root_item_id": None,
+ "workflow_call_depth": None,
+ },
+ )()
+
+ workflow_record = runner._services.workflow_records.get(invocation.workflow_id)
+ coordinator.begin_workflow_call_boundary(invocation, queue_item, workflow_record)
+ child_session = parent_session.waiting_workflow_call_child_session
+ assert child_session is not None
+
+ child_queue_item = coordinator.build_child_queue_item(queue_item, child_session)
+
+ assert child_queue_item.workflow_call_id == parent_session.waiting_workflow_call_execution.id
+ assert child_queue_item.parent_item_id == queue_item.item_id
+ assert child_queue_item.parent_session_id == queue_item.session_id
+ assert child_queue_item.root_item_id == queue_item.item_id
+ assert child_queue_item.workflow_call_depth == 1
+ assert child_queue_item.session_id == child_session.id
+
+
def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
From d769a92c6a61f0b8a161f20ffe8264dbbb61d67b Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Mon, 20 Apr 2026 21:14:24 -0500
Subject: [PATCH 049/100] Add workflow call queue item migration
---
docs/contributing/call_saved_workflow.md | 8 ++
invokeai/app/services/shared/README.md | 2 +
.../app/services/shared/sqlite/sqlite_util.py | 2 +
.../migrations/migration_30.py | 49 ++++++++++++
...st_session_queue_workflow_call_metadata.py | 74 +++++++++++++++++++
tests/test_sqlite_migrator.py | 25 +++++++
6 files changed, 160 insertions(+)
create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py
create mode 100644 tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index accba97be4e..88d64e5ed1b 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -85,6 +85,14 @@ Implemented runtime scaffolding:
- `root_item_id`
- `workflow_call_depth`
- this metadata is used in runtime events now and should survive the later move to durable child queue rows
+- The `session_queue` table now has matching durable columns for that relationship metadata:
+ - `workflow_call_id`
+ - `parent_item_id`
+ - `parent_session_id`
+ - `root_item_id`
+ - `workflow_call_depth`
+ - those columns currently round-trip through `SessionQueueItem`, but child workflow executions are not yet inserted as
+ their own queue rows
- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
- Dynamic call arguments now execute end-to-end in the current runner path:
- literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index e2641801def..12c6dcedf2e 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -144,6 +144,8 @@ Workflow-call note:
- Child `SessionQueueItem` wrappers created by the coordinator now carry explicit relationship metadata such as
`workflow_call_id`, `parent_item_id`, `parent_session_id`, `root_item_id`, and `workflow_call_depth`, even though
child executions are not yet persisted as separate queue rows.
+- The `session_queue` schema now has matching columns for those relationship fields, so the identifiers are durable
+ once child workflow executions start being represented as real queue rows.
- This is a tactical step to keep the feature testable and should eventually be replaced by a first-class parent/child
execution mechanism with explicit queue-visible child lifecycle and return propagation.
diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py
index fb8ca9fca38..19e3b897202 100644
--- a/invokeai/app/services/shared/sqlite/sqlite_util.py
+++ b/invokeai/app/services/shared/sqlite/sqlite_util.py
@@ -32,6 +32,7 @@
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import build_migration_30
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
@@ -81,6 +82,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_27())
migrator.register_migration(build_migration_28())
migrator.register_migration(build_migration_29())
+ migrator.register_migration(build_migration_30())
migrator.run_migrations()
return db
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py
new file mode 100644
index 00000000000..d5105a6a463
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py
@@ -0,0 +1,49 @@
+"""Migration 30: Add workflow-call relationship columns to session_queue."""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration30Callback:
+ """Add durable parent/child workflow-call relationship columns to session_queue."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(session_queue);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "workflow_call_id" not in columns:
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow_call_id TEXT;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_workflow_call_id ON session_queue(workflow_call_id);")
+
+ if "parent_item_id" not in columns:
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN parent_item_id INTEGER;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_parent_item_id ON session_queue(parent_item_id);")
+
+ if "parent_session_id" not in columns:
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN parent_session_id TEXT;")
+ cursor.execute(
+ "CREATE INDEX IF NOT EXISTS idx_session_queue_parent_session_id ON session_queue(parent_session_id);"
+ )
+
+ if "root_item_id" not in columns:
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN root_item_id INTEGER;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_root_item_id ON session_queue(root_item_id);")
+
+ if "workflow_call_depth" not in columns:
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow_call_depth INTEGER;")
+ cursor.execute(
+ "CREATE INDEX IF NOT EXISTS idx_session_queue_workflow_call_depth ON session_queue(workflow_call_depth);"
+ )
+
+
+def build_migration_30() -> Migration:
+ return Migration(
+ from_version=29,
+ to_version=30,
+ callback=Migration30Callback(),
+ )
diff --git a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
new file mode 100644
index 00000000000..ab0370f465a
--- /dev/null
+++ b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
@@ -0,0 +1,74 @@
+"""Tests for workflow-call relationship metadata on session_queue items."""
+
+import uuid
+
+import pytest
+
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
+from invokeai.app.services.shared.graph import Graph, GraphExecutionState
+
+
+@pytest.fixture
+def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
+ db = mock_invoker.services.board_records._db
+ queue = SqliteSessionQueue(db=db)
+ queue.start(mock_invoker)
+ return queue
+
+
+def test_get_queue_item_round_trips_workflow_call_metadata(session_queue: SqliteSessionQueue) -> None:
+ session = GraphExecutionState(graph=Graph())
+ session_json = session.model_dump_json(warnings=False)
+
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id,
+ session,
+ session_id,
+ batch_id,
+ field_values,
+ priority,
+ workflow,
+ origin,
+ destination,
+ retried_from_item_id,
+ user_id,
+ workflow_call_id,
+ parent_item_id,
+ parent_session_id,
+ root_item_id,
+ workflow_call_depth
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ session_json,
+ session.id,
+ str(uuid.uuid4()),
+ None,
+ 0,
+ None,
+ None,
+ None,
+ None,
+ "user-1",
+ "workflow-call-1",
+ 11,
+ "parent-session-1",
+ 7,
+ 3,
+ ),
+ )
+ item_id = cursor.lastrowid
+
+ queue_item = session_queue.get_queue_item(item_id)
+
+ assert queue_item.workflow_call_id == "workflow-call-1"
+ assert queue_item.parent_item_id == 11
+ assert queue_item.parent_session_id == "parent-session-1"
+ assert queue_item.root_item_id == 7
+ assert queue_item.workflow_call_depth == 3
diff --git a/tests/test_sqlite_migrator.py b/tests/test_sqlite_migrator.py
index e03224b5a9a..2939dd3ddd9 100644
--- a/tests/test_sqlite_migrator.py
+++ b/tests/test_sqlite_migrator.py
@@ -375,6 +375,31 @@ def test_migration_27_creates_users_table(logger: Logger) -> None:
db._conn.close()
+def test_migration_30_adds_workflow_call_columns_to_session_queue(logger: Logger) -> None:
+ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import Migration30Callback
+
+ db = SqliteDatabase(db_path=None, logger=logger, verbose=False)
+ cursor = db._conn.cursor()
+
+ cursor.execute("CREATE TABLE IF NOT EXISTS session_queue (item_id INTEGER PRIMARY KEY);")
+ db._conn.commit()
+
+ migration_callback = Migration30Callback()
+ migration_callback(cursor)
+ db._conn.commit()
+
+ cursor.execute("PRAGMA table_info(session_queue);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ assert "workflow_call_id" in columns
+ assert "parent_item_id" in columns
+ assert "parent_session_id" in columns
+ assert "root_item_id" in columns
+ assert "workflow_call_depth" in columns
+
+ db._conn.close()
+
+
def test_migration_27_with_existing_client_state_data(logger: Logger) -> None:
"""Test that migration 27 correctly migrates existing data from the old client_state schema."""
import json
From c5d7b02fd5062550d7954f5537ba2b3e3d885371 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 21 Apr 2026 06:38:25 -0500
Subject: [PATCH 050/100] Queue child workflow executions and suspend parents
---
docs/contributing/call_saved_workflow.md | 56 ++--
.../session_processor_default.py | 102 ++++---
.../session_queue/session_queue_base.py | 17 ++
.../session_queue/session_queue_common.py | 5 +-
.../session_queue/session_queue_sqlite.py | 79 ++++-
invokeai/app/services/shared/README.md | 22 +-
...st_session_queue_workflow_call_metadata.py | 106 +++++++
.../test_session_processor_shutdown.py | 289 ++++++++++++++----
8 files changed, 533 insertions(+), 143 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 88d64e5ed1b..f2afab62120 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -73,26 +73,27 @@ Implemented runtime scaffolding:
- creates a child `GraphExecutionState`
- attaches that child session to the waiting parent session
- `WorkflowCallCoordinator` now owns the temporary parent/child orchestration path:
- - when a session is waiting on a workflow call, the coordinator runs the attached child session as a distinct
- execution unit
+ - when a session is waiting on a workflow call, the coordinator suspends the parent queue item and enqueues a real
+ child queue item
- when the child finishes successfully, the coordinator resumes the parent session
- the parent is completed with the child `workflow_return` collection during that resume path
- - if the child fails, the coordinator fails the suspended parent `call_saved_workflow` node
-- Child `SessionQueueItem` wrappers now carry explicit relationship metadata during temporary attached-session execution:
+ - if the child fails, the coordinator fails the suspended parent `call_saved_workflow` node and cascades that
+ failure upward through any parent call chain
+- Child `SessionQueueItem` rows now carry explicit relationship metadata:
- `workflow_call_id`
- `parent_item_id`
- `parent_session_id`
- `root_item_id`
- `workflow_call_depth`
- - this metadata is used in runtime events now and should survive the later move to durable child queue rows
+ - this metadata is now used directly by queue-visible child execution and parent resume/failure handling
- The `session_queue` table now has matching durable columns for that relationship metadata:
- `workflow_call_id`
- `parent_item_id`
- `parent_session_id`
- `root_item_id`
- `workflow_call_depth`
- - those columns currently round-trip through `SessionQueueItem`, but child workflow executions are not yet inserted as
- their own queue rows
+ - child workflow executions are now inserted as their own pending queue rows using those columns
+- Parent queue items now enter a real `waiting` status while suspended on a child workflow execution.
- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
- Dynamic call arguments now execute end-to-end in the current runner path:
- literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node
@@ -108,10 +109,6 @@ Implemented conversion helper:
What is still not implemented:
-- the child workflow is still executed through a temporary attached-session path, not yet as its own queue item or
- scheduler-visible unit
-- the child workflow is not persisted as its own queue item
-- parent-child queue/session identifiers are not yet formalized beyond the attached child `GraphExecutionState`
- saved workflows containing batch-special nodes such as `image_batch` are not supported by the current child graph
reconstruction path and must fail with a clear domain error rather than a low-level constructor error
@@ -119,10 +116,10 @@ Conclusion:
- the editor contract is largely in place
- the parent-side runtime call boundary is in place
-- child execution, argument forwarding, and explicit child return capture now work through the temporary
- coordinator-owned
- attached-session path
-- first-class child execution semantics are the remaining major runtime step
+- child execution, argument forwarding, explicit child return capture, suspended parent status, queue-visible child
+ rows, and upward failure cascade now work
+- the remaining major runtime work is to harden and generalize the parent/child scheduler model rather than prove the
+ basic call boundary
## Architectural Direction
@@ -221,12 +218,10 @@ Current limitation:
- batch-special nodes from `invokeai.app.invocations.batch` are not yet supported in called workflows
- until the child execution path is closer to normal session execution, batch-special nodes should fail early with a
clear unsupported-feature error
-- the current child execution path runs inline inside `DefaultSessionRunner`, so child execution is not yet a
- first-class queue/session entity
-- the current inline runner path is an intentionally temporary implementation step used to keep the feature testable
- while the durable parent-child execution architecture is being built
-- the plan is to replace the inline runner path with a first-class parent-child execution mechanism once return-value
- propagation and child-session lifecycle handling are implemented cleanly
+- the current queue-visible child execution path still relies on `WorkflowCallCoordinator` to resume or fail parents
+ directly rather than a more general queue scheduler abstraction
+- the current implementation is still an intermediate architecture step, but it is now materially closer to the
+ intended durable parent/child model than the earlier inline-runner path
### 5. Return Values
@@ -299,17 +294,15 @@ Current insertion points already used:
- `DefaultSessionRunner.run_node()` detects `call_saved_workflow` and enters boundary state
- `GraphExecutionState` stores the waiting/call-stack state and attached child session
-- `WorkflowCallCoordinator` currently runs the attached child session, then resumes the parent and completes the call
- node with the child `workflow_return` collection
-- child queue-item wrappers already carry stable parent/child identifiers even though child executions are not yet
- persisted as their own queue rows
+- `WorkflowCallCoordinator` currently enqueues child workflow executions as real queue rows, then resumes or fails the
+ parent when those child rows complete
+- child queue items already carry stable parent/child identifiers in both runtime objects and durable queue columns
Next runtime work still needed:
-- move child execution off the temporary attached-session processor path and onto the intended first-class parent-child
- runtime boundary
-- persist and/or formalize parent-child identifiers beyond the in-memory attached child session
-- define how the child completion or failure is delivered back to the suspended parent
+- generalize parent/child queue scheduling so parent resume/failure is not coordinated only by
+ `WorkflowCallCoordinator`
+- decide whether parent resumption should remain immediate on child completion or become a more explicit scheduler step
- replace the current unsupported batch-special-node limitation by routing child execution through machinery that can
honor ordinary Invoke batch semantics
@@ -421,5 +414,6 @@ The current branch is at the point where:
- parent call-boundary state exists
- child execution state can be created from the selected saved workflow
-- child execution, argument forwarding, and explicit return propagation work through the inline runner path
-- but long-term child-session execution semantics are still missing
+- child execution, argument forwarding, explicit return propagation, suspended parent status, queue-visible child rows,
+ and upward failure cascade work through the current coordinator + queue path
+- but long-term generalized parent/child scheduling semantics are still missing
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index 3680163948b..4c641d10594 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -43,7 +43,7 @@
class WorkflowCallCoordinator:
- """Coordinates temporary parent/child workflow-call execution."""
+ """Coordinates parent/child workflow-call execution."""
def __init__(self, session_runner: "DefaultSessionRunner") -> None:
self._session_runner = session_runner
@@ -123,7 +123,7 @@ def begin_workflow_call_boundary(
invocation: CallSavedWorkflowInvocation,
queue_item: SessionQueueItem,
workflow_record,
- ) -> None:
+ ) -> SessionQueueItem:
call_frame = queue_item.session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
child_graph = build_graph_from_workflow(workflow_record.workflow.model_dump())
apply_workflow_inputs_to_graph(
@@ -134,6 +134,14 @@ def begin_workflow_call_boundary(
child_session = queue_item.session.create_child_workflow_execution_state(child_graph, call_frame)
queue_item.session.begin_waiting_on_workflow_call(call_frame)
queue_item.session.attach_waiting_workflow_call_child_session(child_session)
+ self._session_runner._services.session_queue.set_queue_item_session(queue_item.item_id, queue_item.session)
+ child_queue_item = self._session_runner._services.session_queue.enqueue_workflow_call_child(
+ parent_queue_item=queue_item,
+ child_session=child_session,
+ )
+ self._session_runner._services.session_queue.suspend_queue_item(queue_item.item_id)
+ queue_item.status = "waiting"
+ return child_queue_item
def resume_waiting_workflow_call(self, queue_item: SessionQueueItem) -> None:
invocation = self.get_waiting_workflow_call_invocation(queue_item)
@@ -156,51 +164,57 @@ def fail_waiting_workflow_call(self, queue_item: SessionQueueItem, error_message
error_traceback=error_message,
)
- def run_queue_item(self, queue_item: SessionQueueItem) -> None:
- self._session_runner._on_before_run_session(queue_item=queue_item)
-
- active_queue_item = queue_item
- parent_queue_items: list[SessionQueueItem] = []
-
- while True:
- self._session_runner._run_session_loop(active_queue_item)
-
- if active_queue_item.session.has_error():
- if not parent_queue_items:
- break
- parent_queue_item = parent_queue_items.pop()
- waiting_frame = parent_queue_item.session.waiting_workflow_call
- if waiting_frame is None:
- raise ValueError("Parent queue item is missing workflow call waiting state.")
- self.fail_waiting_workflow_call(
- parent_queue_item,
- f"The selected saved workflow '{waiting_frame.workflow_id}' failed during child execution.",
- )
- active_queue_item = parent_queue_item
- break
+ def _get_parent_queue_item(self, child_queue_item: SessionQueueItem) -> SessionQueueItem:
+ parent_item_id = child_queue_item.parent_item_id
+ if parent_item_id is None:
+ raise ValueError("Child workflow queue item is missing parent_item_id metadata.")
+ return self._session_runner._services.session_queue.get_queue_item(parent_item_id)
- if active_queue_item.session.is_waiting_on_workflow_call():
- child_session = active_queue_item.session.waiting_workflow_call_child_session
- if child_session is None:
- raise ValueError("Execution state is waiting on a workflow call but has no attached child session.")
- parent_queue_items.append(active_queue_item)
- active_queue_item = self.build_child_queue_item(active_queue_item, child_session)
- continue
-
- if parent_queue_items:
- parent_queue_item = parent_queue_items.pop()
- try:
- self.resume_waiting_workflow_call(parent_queue_item)
- except Exception as e:
- self.fail_waiting_workflow_call(parent_queue_item, str(e))
- active_queue_item = parent_queue_item
- break
- active_queue_item = parent_queue_item
- continue
+ def _resume_parent_from_completed_child(self, child_queue_item: SessionQueueItem) -> None:
+ parent_queue_item = self._get_parent_queue_item(child_queue_item)
+ parent_queue_item.session.waiting_workflow_call_child_session = child_queue_item.session
+ try:
+ self.resume_waiting_workflow_call(parent_queue_item)
+ except Exception as e:
+ self.fail_waiting_workflow_call(parent_queue_item, str(e))
+ parent_queue_item = self._session_runner._services.session_queue.get_queue_item(parent_queue_item.item_id)
+ if getattr(parent_queue_item, "parent_item_id", None) is not None:
+ self._fail_parent_from_failed_child(parent_queue_item)
+ return
+ parent_queue_item = self._session_runner._services.session_queue.set_queue_item_session(
+ parent_queue_item.item_id, parent_queue_item.session
+ )
+ if parent_queue_item.session.is_complete():
+ parent_queue_item = self._session_runner._services.session_queue.complete_queue_item(
+ parent_queue_item.item_id
+ )
+ if getattr(parent_queue_item, "parent_item_id", None) is not None:
+ self._resume_parent_from_completed_child(parent_queue_item)
+ return
+ self._session_runner._services.session_queue.resume_queue_item(parent_queue_item.item_id)
- break
+ def _fail_parent_from_failed_child(self, child_queue_item: SessionQueueItem) -> None:
+ parent_queue_item = self._get_parent_queue_item(child_queue_item)
+ waiting_frame = parent_queue_item.session.waiting_workflow_call
+ if waiting_frame is None:
+ raise ValueError("Parent queue item is missing workflow call waiting state.")
+ child_error_message = getattr(child_queue_item, "error_message", None) or (
+ f"The selected saved workflow '{waiting_frame.workflow_id}' failed during child execution."
+ )
+ self.fail_waiting_workflow_call(parent_queue_item, child_error_message)
+ parent_queue_item = self._session_runner._services.session_queue.get_queue_item(parent_queue_item.item_id)
+ if getattr(parent_queue_item, "parent_item_id", None) is not None:
+ self._fail_parent_from_failed_child(parent_queue_item)
- self._session_runner._on_after_run_session(queue_item=queue_item)
+ def run_queue_item(self, queue_item: SessionQueueItem) -> None:
+ self._session_runner.run(queue_item)
+ updated_queue_item = self._session_runner._services.session_queue.get_queue_item(queue_item.item_id)
+ if getattr(updated_queue_item, "parent_item_id", None) is None:
+ return
+ if updated_queue_item.status == "completed":
+ self._resume_parent_from_completed_child(updated_queue_item)
+ elif updated_queue_item.status in ["failed", "canceled"]:
+ self._fail_parent_from_failed_child(updated_queue_item)
class DefaultSessionRunner(SessionRunnerBase):
diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py
index 14b93d97fc7..3b4c0071603 100644
--- a/invokeai/app/services/session_queue/session_queue_base.py
+++ b/invokeai/app/services/session_queue/session_queue_base.py
@@ -94,6 +94,16 @@ def complete_queue_item(self, item_id: int) -> SessionQueueItem:
"""Completes a session queue item"""
pass
+ @abstractmethod
+ def suspend_queue_item(self, item_id: int) -> SessionQueueItem:
+ """Suspends a session queue item while waiting on a child workflow execution."""
+ pass
+
+ @abstractmethod
+ def resume_queue_item(self, item_id: int) -> SessionQueueItem:
+ """Resumes a suspended session queue item by returning it to pending state."""
+ pass
+
@abstractmethod
def cancel_queue_item(self, item_id: int) -> SessionQueueItem:
"""Cancels a session queue item"""
@@ -189,6 +199,13 @@ def set_queue_item_session(self, item_id: int, session: GraphExecutionState) ->
"""Sets the session for a session queue item. Use this to update the session state."""
pass
+ @abstractmethod
+ def enqueue_workflow_call_child(
+ self, parent_queue_item: SessionQueueItem, child_session: GraphExecutionState
+ ) -> SessionQueueItem:
+ """Enqueues a child workflow execution linked to a suspended parent queue item."""
+ pass
+
@abstractmethod
def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
"""Retries the given queue items"""
diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py
index e63365160f3..c7eb4d2cc87 100644
--- a/invokeai/app/services/session_queue/session_queue_common.py
+++ b/invokeai/app/services/session_queue/session_queue_common.py
@@ -172,7 +172,7 @@ def validate_graph(cls, v: Graph):
DEFAULT_QUEUE_ID = "default"
SYSTEM_USER_ID = "system" # Default user_id for system-generated queue items
-QUEUE_ITEM_STATUS = Literal["pending", "in_progress", "completed", "failed", "canceled"]
+QUEUE_ITEM_STATUS = Literal["pending", "in_progress", "waiting", "completed", "failed", "canceled"]
class ItemIdsResult(BaseModel):
@@ -315,6 +315,7 @@ class SessionQueueStatus(BaseModel):
session_id: Optional[str] = Field(description="The current queue item's session id")
pending: int = Field(..., description="Number of queue items with status 'pending'")
in_progress: int = Field(..., description="Number of queue items with status 'in_progress'")
+ waiting: int = Field(..., description="Number of queue items with status 'waiting'")
completed: int = Field(..., description="Number of queue items with status 'complete'")
failed: int = Field(..., description="Number of queue items with status 'error'")
canceled: int = Field(..., description="Number of queue items with status 'canceled'")
@@ -326,6 +327,7 @@ class SessionQueueCountsByDestination(BaseModel):
destination: str = Field(..., description="The destination of queue items included in this status")
pending: int = Field(..., description="Number of queue items with status 'pending' for the destination")
in_progress: int = Field(..., description="Number of queue items with status 'in_progress' for the destination")
+ waiting: int = Field(..., description="Number of queue items with status 'waiting' for the destination")
completed: int = Field(..., description="Number of queue items with status 'complete' for the destination")
failed: int = Field(..., description="Number of queue items with status 'error' for the destination")
canceled: int = Field(..., description="Number of queue items with status 'canceled' for the destination")
@@ -339,6 +341,7 @@ class BatchStatus(BaseModel):
destination: str | None = Field(..., description="The destination of the batch")
pending: int = Field(..., description="Number of queue items with status 'pending'")
in_progress: int = Field(..., description="Number of queue items with status 'in_progress'")
+ waiting: int = Field(..., description="Number of queue items with status 'waiting'")
completed: int = Field(..., description="Number of queue items with status 'complete'")
failed: int = Field(..., description="Number of queue items with status 'error'")
canceled: int = Field(..., description="Number of queue items with status 'canceled'")
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index 172dc08d559..f8c82925139 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -65,15 +65,16 @@ def __init__(self, db: SqliteDatabase) -> None:
def _set_in_progress_to_canceled(self) -> None:
"""
- Sets all in_progress queue items to canceled. Run on app startup, not associated with any queue.
- This is necessary because the invoker may have been killed while processing a queue item.
+ Sets all in_progress or waiting queue items to canceled. Run on app startup, not associated with any queue.
+ This is necessary because the invoker may have been killed while processing a queue item or while a parent
+ queue item was suspended waiting on a child workflow execution.
"""
with self._db.transaction() as cursor:
cursor.execute(
"""--sql
UPDATE session_queue
SET status = 'canceled'
- WHERE status = 'in_progress';
+ WHERE status IN ('in_progress', 'waiting');
"""
)
@@ -437,6 +438,14 @@ def complete_queue_item(self, item_id: int) -> SessionQueueItem:
queue_item = self._set_queue_item_status(item_id=item_id, status="completed")
return queue_item
+ def suspend_queue_item(self, item_id: int) -> SessionQueueItem:
+ queue_item = self._set_queue_item_status(item_id=item_id, status="waiting")
+ return queue_item
+
+ def resume_queue_item(self, item_id: int) -> SessionQueueItem:
+ queue_item = self._set_queue_item_status(item_id=item_id, status="pending")
+ return queue_item
+
def fail_queue_item(
self,
item_id: int,
@@ -727,6 +736,67 @@ def set_queue_item_session(self, item_id: int, session: GraphExecutionState) ->
)
return self.get_queue_item(item_id)
+ def enqueue_workflow_call_child(
+ self, parent_queue_item: SessionQueueItem, child_session: GraphExecutionState
+ ) -> SessionQueueItem:
+ workflow_call_execution = parent_queue_item.session.waiting_workflow_call_execution
+ if workflow_call_execution is None:
+ raise ValueError("Parent queue item is missing active workflow call execution metadata.")
+
+ session_json = child_session.model_dump_json(warnings=False, exclude_none=True)
+ root_item_id = parent_queue_item.root_item_id or parent_queue_item.item_id
+
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id,
+ session,
+ session_id,
+ batch_id,
+ field_values,
+ priority,
+ workflow,
+ origin,
+ destination,
+ retried_from_item_id,
+ user_id,
+ workflow_call_id,
+ parent_item_id,
+ parent_session_id,
+ root_item_id,
+ workflow_call_depth,
+ status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
+ """,
+ (
+ parent_queue_item.queue_id,
+ session_json,
+ child_session.id,
+ parent_queue_item.batch_id,
+ None,
+ parent_queue_item.priority,
+ None,
+ parent_queue_item.origin,
+ parent_queue_item.destination,
+ None,
+ parent_queue_item.user_id,
+ workflow_call_execution.id,
+ parent_queue_item.item_id,
+ parent_queue_item.session_id,
+ root_item_id,
+ workflow_call_execution.depth,
+ ),
+ )
+ item_id = cursor.lastrowid
+
+ queue_item = self.get_queue_item(item_id)
+ batch_status = self.get_batch_status(queue_id=queue_item.queue_id, batch_id=queue_item.batch_id)
+ queue_status = self.get_queue_status(queue_id=queue_item.queue_id)
+ self.__invoker.services.events.emit_queue_item_status_changed(queue_item, batch_status, queue_status)
+ return queue_item
+
def list_queue_items(
self,
queue_id: str,
@@ -880,6 +950,7 @@ def get_queue_status(self, queue_id: str, user_id: Optional[str] = None) -> Sess
batch_id=current_item.batch_id if show_current_item else None,
pending=counts.get("pending", 0),
in_progress=counts.get("in_progress", 0),
+ waiting=counts.get("waiting", 0),
completed=counts.get("completed", 0),
failed=counts.get("failed", 0),
canceled=counts.get("canceled", 0),
@@ -912,6 +983,7 @@ def get_batch_status(self, queue_id: str, batch_id: str, user_id: Optional[str]
queue_id=queue_id,
pending=counts.get("pending", 0),
in_progress=counts.get("in_progress", 0),
+ waiting=counts.get("waiting", 0),
completed=counts.get("completed", 0),
failed=counts.get("failed", 0),
canceled=counts.get("canceled", 0),
@@ -943,6 +1015,7 @@ def get_counts_by_destination(
destination=destination,
pending=counts.get("pending", 0),
in_progress=counts.get("in_progress", 0),
+ waiting=counts.get("waiting", 0),
completed=counts.get("completed", 0),
failed=counts.get("failed", 0),
canceled=counts.get("canceled", 0),
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 12c6dcedf2e..c712986a8e0 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -138,16 +138,16 @@ Workflow-call note:
- `GraphExecutionState` can represent a paused parent execution plus an attached child execution state, but it does not
itself orchestrate child execution.
-- In the current temporary implementation, `DefaultSessionRunner.run_node()` establishes the workflow call boundary and
- attaches the child execution state, while `WorkflowCallCoordinator` runs that attached child session, resumes the
- parent, and completes the parent `call_saved_workflow` node with the child `workflow_return` output.
-- Child `SessionQueueItem` wrappers created by the coordinator now carry explicit relationship metadata such as
+- In the current implementation, `DefaultSessionRunner.run_node()` establishes the workflow call boundary and attaches
+ the child execution state, while `WorkflowCallCoordinator` suspends the parent queue item, enqueues a real child
+ queue item, and later resumes or fails the parent based on that child queue row's outcome.
+- Child `SessionQueueItem` rows created by the coordinator now carry explicit relationship metadata such as
`workflow_call_id`, `parent_item_id`, `parent_session_id`, `root_item_id`, and `workflow_call_depth`, even though
- child executions are not yet persisted as separate queue rows.
-- The `session_queue` schema now has matching columns for those relationship fields, so the identifiers are durable
- once child workflow executions start being represented as real queue rows.
-- This is a tactical step to keep the feature testable and should eventually be replaced by a first-class parent/child
- execution mechanism with explicit queue-visible child lifecycle and return propagation.
+ the higher-level scheduler semantics are still evolving.
+- The `session_queue` schema now has matching columns for those relationship fields, and parent queue items can enter a
+ `waiting` status while suspended on a child workflow execution.
+- This is still an intermediate architecture step and should eventually be replaced by a more general parent/child
+ execution mechanism rather than coordinator-specific resume/fail behavior.
### 4.3 Runtime helper classes
@@ -274,8 +274,8 @@ In normal execution, all runtime expansion occurs in `execution_graph` with trac
Current limitation:
-- The attached child execution state is currently executed inline by the session runner rather than as a first-class
- queued or scheduler-visible child execution.
+- Child workflow executions are now represented as first-class queue items, but parent resume/failure is still
+ coordinator-driven rather than part of a generalized queue scheduler contract.
- Called workflows currently require a valid `workflow_return` node to produce a parent-visible result.
## 8) Error Model (selected)
diff --git a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
index ab0370f465a..5007d6f4d67 100644
--- a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
+++ b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
@@ -4,6 +4,7 @@
import pytest
+from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
@@ -72,3 +73,108 @@ def test_get_queue_item_round_trips_workflow_call_metadata(session_queue: Sqlite
assert queue_item.parent_session_id == "parent-session-1"
assert queue_item.root_item_id == 7
assert queue_item.workflow_call_depth == 3
+
+
+def test_enqueue_workflow_call_child_persists_pending_child_queue_item(session_queue: SqliteSessionQueue) -> None:
+ parent_graph = Graph()
+ parent_graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ parent_session = GraphExecutionState(graph=parent_graph)
+ invocation = parent_session.next()
+ assert isinstance(invocation, CallSavedWorkflowInvocation)
+
+ frame = parent_session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
+ child_session = parent_session.create_child_workflow_execution_state(Graph(), frame)
+ parent_session.begin_waiting_on_workflow_call(frame)
+ parent_session.attach_waiting_workflow_call_child_session(child_session)
+
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id,
+ session,
+ session_id,
+ batch_id,
+ field_values,
+ priority,
+ workflow,
+ origin,
+ destination,
+ retried_from_item_id,
+ user_id,
+ status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ parent_session.model_dump_json(warnings=False),
+ parent_session.id,
+ str(uuid.uuid4()),
+ None,
+ 0,
+ None,
+ None,
+ None,
+ None,
+ "user-1",
+ "in_progress",
+ ),
+ )
+ parent_item_id = cursor.lastrowid
+
+ parent_queue_item = session_queue.get_queue_item(parent_item_id)
+ child_queue_item = session_queue.enqueue_workflow_call_child(parent_queue_item, child_session)
+
+ assert child_queue_item.status == "pending"
+ assert child_queue_item.workflow_call_id == parent_session.waiting_workflow_call_execution.id
+ assert child_queue_item.parent_item_id == parent_item_id
+ assert child_queue_item.parent_session_id == parent_session.id
+ assert child_queue_item.root_item_id == parent_item_id
+ assert child_queue_item.workflow_call_depth == 1
+ assert child_queue_item.session_id == child_session.id
+
+
+def test_get_queue_status_counts_waiting_items(session_queue: SqliteSessionQueue) -> None:
+ session = GraphExecutionState(graph=Graph())
+ session_json = session.model_dump_json(warnings=False)
+
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id,
+ session,
+ session_id,
+ batch_id,
+ field_values,
+ priority,
+ workflow,
+ origin,
+ destination,
+ retried_from_item_id,
+ user_id,
+ status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ session_json,
+ session.id,
+ str(uuid.uuid4()),
+ None,
+ 0,
+ None,
+ None,
+ None,
+ None,
+ "user-1",
+ "waiting",
+ ),
+ )
+
+ queue_status = session_queue.get_queue_status("default")
+
+ assert queue_status.waiting == 1
+ assert queue_status.total == 1
diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py
index daeb58c36f6..c939d46c7ae 100644
--- a/tests/app/services/test_session_processor_shutdown.py
+++ b/tests/app/services/test_session_processor_shutdown.py
@@ -331,6 +331,39 @@ def get(self, workflow_id: str):
}
],
)
+ elif workflow_id == "workflow-nested-no-return":
+ workflow_dump = self._workflow_dump(
+ nodes=[
+ self._invocation_node(
+ "nested-call",
+ "call_saved_workflow",
+ {
+ "workflow_id": {"value": "workflow-no-return"},
+ "workflow_inputs": {"value": {}},
+ },
+ ),
+ self._invocation_node(
+ "nested-collection",
+ "integer_collection",
+ {"collection": {"value": [4]}},
+ ),
+ self._invocation_node(
+ "nested-return",
+ "workflow_return",
+ {"collection": {"value": []}},
+ ),
+ ],
+ edges=[
+ {
+ "id": "edge-nested-return",
+ "type": "default",
+ "source": "nested-collection",
+ "sourceHandle": "collection",
+ "target": "nested-return",
+ "targetHandle": "collection",
+ }
+ ],
+ )
elif workflow_id == "workflow-return":
workflow_dump = self._workflow_dump(
nodes=[
@@ -464,34 +497,124 @@ def _build_queue_item(invocation: BaseInvocation):
class _DummySessionQueue:
def __init__(self) -> None:
+ self.items: dict[int, object] = {}
+ self.next_item_id = 100
self.completed_item_ids: list[int] = []
self.session_updates: list[tuple[int, object]] = []
self.failed_item_ids: list[int] = []
+ self.waiting_item_ids: list[int] = []
+ self.resumed_item_ids: list[int] = []
+ self.enqueued_child_item_ids: list[int] = []
+
+ def add_queue_item(self, queue_item):
+ self.items[queue_item.item_id] = queue_item
+ return queue_item
+
+ def _ensure_queue_item(self, item_id: int, session):
+ queue_item = self.items.get(item_id)
+ if queue_item is None:
+ queue_item = SimpleNamespace(
+ item_id=item_id,
+ status="in_progress",
+ session=session,
+ session_id=getattr(session, "id", f"session-{item_id}"),
+ user_id="user-1",
+ queue_id="default",
+ batch_id="batch-1",
+ parent_item_id=None,
+ parent_session_id=None,
+ workflow_call_id=None,
+ root_item_id=None,
+ workflow_call_depth=None,
+ )
+ self.items[item_id] = queue_item
+ return queue_item
+
+ def get_queue_item(self, item_id: int):
+ return self.items[item_id]
def set_queue_item_session(self, item_id: int, session):
+ queue_item = self._ensure_queue_item(item_id, session)
+ queue_item.session = session
self.session_updates.append((item_id, session))
- return type("QueueItem", (), {"item_id": item_id, "status": "in_progress", "session": session})()
+ return queue_item
+
+ def suspend_queue_item(self, item_id: int):
+ queue_item = self._ensure_queue_item(item_id, None)
+ queue_item.status = "waiting"
+ self.waiting_item_ids.append(item_id)
+ return queue_item
+
+ def resume_queue_item(self, item_id: int):
+ queue_item = self._ensure_queue_item(item_id, None)
+ queue_item.status = "pending"
+ self.resumed_item_ids.append(item_id)
+ return queue_item
def complete_queue_item(self, item_id: int):
+ queue_item = self._ensure_queue_item(item_id, None)
+ queue_item.status = "completed"
self.completed_item_ids.append(item_id)
- session = self.session_updates[-1][1]
- return type("QueueItem", (), {"item_id": item_id, "status": "completed", "session": session})()
+ return queue_item
def fail_queue_item(self, item_id: int, error_type: str, error_message: str, error_traceback: str):
+ queue_item = self._ensure_queue_item(item_id, None)
+ queue_item.status = "failed"
+ queue_item.error_type = error_type
+ queue_item.error_message = error_message
+ queue_item.error_traceback = error_traceback
self.failed_item_ids.append(item_id)
- session = self.session_updates[-1][1]
- return type(
+ return queue_item
+
+ def enqueue_workflow_call_child(self, parent_queue_item, child_session):
+ workflow_call_execution = parent_queue_item.session.waiting_workflow_call_execution
+ item_id = self.next_item_id
+ self.next_item_id += 1
+ child_queue_item = type(
"QueueItem",
(),
{
"item_id": item_id,
- "status": "failed",
- "session": session,
- "error_type": error_type,
- "error_message": error_message,
- "error_traceback": error_traceback,
+ "status": "pending",
+ "session": child_session,
+ "session_id": child_session.id,
+ "user_id": getattr(parent_queue_item, "user_id", "user-1"),
+ "queue_id": getattr(parent_queue_item, "queue_id", "default"),
+ "batch_id": getattr(parent_queue_item, "batch_id", "batch-1"),
+ "origin": getattr(parent_queue_item, "origin", None),
+ "destination": getattr(parent_queue_item, "destination", None),
+ "priority": getattr(parent_queue_item, "priority", 0),
+ "workflow_call_id": workflow_call_execution.id if workflow_call_execution is not None else None,
+ "parent_item_id": parent_queue_item.item_id,
+ "parent_session_id": parent_queue_item.session_id,
+ "root_item_id": getattr(parent_queue_item, "root_item_id", None) or parent_queue_item.item_id,
+ "workflow_call_depth": (
+ workflow_call_execution.depth if workflow_call_execution is not None else None
+ ),
+ "workflow": None,
},
)()
+ self.add_queue_item(child_queue_item)
+ self.enqueued_child_item_ids.append(item_id)
+ return child_queue_item
+
+ def dequeue(self):
+ pending_item_ids = sorted(item_id for item_id, item in self.items.items() if item.status == "pending")
+ if not pending_item_ids:
+ return None
+ queue_item = self.items[pending_item_ids[0]]
+ queue_item.status = "in_progress"
+ return queue_item
+
+
+def _drain_workflow_call_queue(coordinator: WorkflowCallCoordinator, session_queue: _DummySessionQueue, queue_item) -> None:
+ session_queue.add_queue_item(queue_item)
+ coordinator.run_queue_item(queue_item)
+ while True:
+ next_queue_item = session_queue.dequeue()
+ if next_queue_item is None:
+ return
+ coordinator.run_queue_item(next_queue_item)
class _WaitingSession:
@@ -521,6 +644,7 @@ def __init__(self, invocation_id: str) -> None:
self.completed: list[tuple[str, object]] = []
self.frames: list[WorkflowCallFrame] = []
self.waiting: WorkflowCallFrame | None = None
+ self.waiting_workflow_call_execution = None
self.waiting_workflow_call_child_session = None
self.errors: dict[str, str] = {}
self.execution_graph = Graph()
@@ -543,6 +667,7 @@ def create_child_workflow_execution_state(self, graph: Graph, frame: WorkflowCal
return GraphExecutionState(graph=graph, workflow_call_stack=[frame])
def attach_waiting_workflow_call_child_session(self, child_session: GraphExecutionState) -> None:
+ self.waiting_workflow_call_execution = SimpleNamespace(id="workflow-call-1", depth=1)
self.waiting_workflow_call_child_session = child_session
def end_waiting_on_workflow_call(self) -> None:
@@ -691,6 +816,7 @@ def test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch:
"session_id": "session-id",
},
)()
+ session_queue.add_queue_item(queue_item)
runner._on_after_run_session(queue_item=queue_item)
@@ -751,6 +877,7 @@ def test_run_node_fails_cleanly_for_unsupported_batch_special_child_workflow(
"session": session,
},
)()
+ runner._services.session_queue.add_queue_item(queue_item)
runner.run_node(invocation=invocation, queue_item=queue_item)
@@ -794,6 +921,7 @@ def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch:
"session_id": "session-id",
},
)()
+ session_queue.add_queue_item(queue_item)
runner.run(queue_item=queue_item)
@@ -802,7 +930,7 @@ def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch:
assert session_queue.completed_item_ids == []
-def test_workflow_call_coordinator_runs_child_session_and_resumes_parent_workflow_call(
+def test_workflow_call_coordinator_suspends_parent_and_enqueues_child_queue_item(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_queue = _DummySessionQueue()
@@ -824,40 +952,30 @@ def test_workflow_call_coordinator_runs_child_session_and_resumes_parent_workflo
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
+ session_queue.add_queue_item(queue_item)
coordinator.run_queue_item(queue_item)
- assert not session.is_waiting_on_workflow_call()
- assert "downstream-if" in session.executed
- parent_outputs = [
- output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
- ]
- downstream_outputs = [
- output for invocation, _queue_item, output in events.completed if invocation.get_type() == "if"
- ]
- assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [3]
- assert len(downstream_outputs) == 1
- assert downstream_outputs[0].value == [3]
+ assert session.is_waiting_on_workflow_call()
+ assert session_queue.waiting_item_ids == [1]
+ assert session_queue.enqueued_child_item_ids == [100]
+ child_queue_item = session_queue.get_queue_item(100)
+ assert child_queue_item.status == "pending"
+ assert child_queue_item.parent_item_id == queue_item.item_id
+ assert child_queue_item.workflow_call_id == session.waiting_workflow_call_execution.id
+ assert "downstream-if" not in session.executed
+ assert events.completed == []
child_started_queue_items = [
child_queue_item
for child_queue_item, invocation in events.started
if invocation.get_type() != "call_saved_workflow" and child_queue_item.session_id != queue_item.session_id
]
- assert len(child_started_queue_items) > 0
- assert all(child_queue_item.workflow_call_id is not None for child_queue_item in child_started_queue_items)
- assert all(child_queue_item.parent_item_id == queue_item.item_id for child_queue_item in child_started_queue_items)
- assert all(
- child_queue_item.parent_session_id == queue_item.session_id for child_queue_item in child_started_queue_items
- )
- assert all(child_queue_item.root_item_id == queue_item.item_id for child_queue_item in child_started_queue_items)
- assert all(child_queue_item.workflow_call_depth == 1 for child_queue_item in child_started_queue_items)
- assert len(session.workflow_call_history) == 1
- assert session.workflow_call_history[0].status == "completed"
- assert session.workflow_call_history[0].child_session_id is not None
- assert session_queue.completed_item_ids == [1]
+ assert child_started_queue_items == []
+ assert session.workflow_call_history == []
assert events.errors == []
@@ -928,10 +1046,12 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
assert not session.is_waiting_on_workflow_call()
assert "downstream-if" in session.executed
@@ -955,8 +1075,9 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
assert len(downstream_outputs) == 1
assert downstream_outputs[0].value == [3]
assert events.errors == []
- assert session_queue.completed_item_ids == [1]
- assert session_queue.session_updates == [(1, session)]
+ assert session_queue.completed_item_ids == [100, 1]
+ assert session_queue.waiting_item_ids == [1]
+ assert session_queue.resumed_item_ids == [1]
def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -1008,13 +1129,15 @@ def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
assert not session.is_waiting_on_workflow_call()
- assert session_queue.completed_item_ids == [1]
+ assert session_queue.completed_item_ids == [100, 1]
assert "call-node" in session.executed
child_add_outputs = [
output
@@ -1050,10 +1173,12 @@ def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypa
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
child_return_outputs = [
output
@@ -1069,7 +1194,7 @@ def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypa
assert child_return_outputs[0].collection == [7, 8]
assert len(parent_outputs) == 1
assert parent_outputs[0].collection == [7, 8]
- assert session_queue.completed_item_ids == [1]
+ assert session_queue.completed_item_ids == [100, 1]
assert events.errors == []
@@ -1091,15 +1216,18 @@ def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeyp
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
assert session.has_error()
assert len(session.workflow_call_history) == 1
assert session.workflow_call_history[0].status == "failed"
assert session.workflow_call_history[0].error_message is not None
+ assert session_queue.completed_item_ids == [100]
assert session_queue.failed_item_ids == [1]
assert len(events.errors) == 1
assert "workflow_return" in events.errors[0][3]
@@ -1123,10 +1251,12 @@ def test_run_respects_child_dependency_readiness(monkeypatch: pytest.MonkeyPatch
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
child_completions = [
(child_queue_item.session.prepared_source_mapping[invocation.id], output)
@@ -1136,7 +1266,7 @@ def test_run_respects_child_dependency_readiness(monkeypatch: pytest.MonkeyPatch
assert [source_id for source_id, _output in child_completions] == ["child-add-1", "child-add-2"]
assert child_completions[0][1].value == 3
assert child_completions[1][1].value == 7
- assert session_queue.completed_item_ids == [1]
+ assert session_queue.completed_item_ids == [100, 1]
assert events.errors == []
@@ -1158,10 +1288,12 @@ def test_run_respects_child_if_branching(monkeypatch: pytest.MonkeyPatch) -> Non
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
child_if_outputs = [
output
@@ -1170,7 +1302,7 @@ def test_run_respects_child_if_branching(monkeypatch: pytest.MonkeyPatch) -> Non
]
assert len(child_if_outputs) == 1
assert child_if_outputs[0].value == 5
- assert session_queue.completed_item_ids == [1]
+ assert session_queue.completed_item_ids == [100, 1]
assert events.errors == []
@@ -1192,10 +1324,12 @@ def test_run_supports_nested_call_saved_workflow_execution(monkeypatch: pytest.M
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
call_started = [
queue_item.session.prepared_source_mapping[invocation.id]
@@ -1226,10 +1360,43 @@ def test_run_supports_nested_call_saved_workflow_execution(monkeypatch: pytest.M
assert leaf_add_outputs[0].value == 11
assert len(nested_add_outputs) == 1
assert nested_add_outputs[0].value == 4
- assert session_queue.completed_item_ids == [1]
+ assert session_queue.completed_item_ids == [101, 100, 1]
assert events.errors == []
+def test_run_cascades_nested_child_workflow_failures_to_all_parents(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-nested-no-return"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
+ },
+ )()
+
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
+
+ assert session.has_error()
+ assert session_queue.completed_item_ids == [101]
+ assert session_queue.failed_item_ids == [100, 1]
+ assert len(events.errors) == 2
+ assert "workflow_return" in events.errors[0][3]
+ assert "workflow_return" in events.errors[1][3]
+
+
def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
@@ -1254,10 +1421,12 @@ def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypa
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
child_add_outputs = [
output
@@ -1267,7 +1436,7 @@ def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypa
]
assert len(child_add_outputs) == 1
assert child_add_outputs[0].value == 9
- assert session_queue.completed_item_ids == [1]
+ assert session_queue.completed_item_ids == [100, 1]
assert events.errors == []
@@ -1291,10 +1460,12 @@ def test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkey
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
- processor.session_runner.workflow_call_coordinator.run_queue_item(queue_item)
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_coordinator, session_queue, queue_item)
child_add_outputs = [
output
@@ -1304,7 +1475,7 @@ def test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkey
]
assert len(child_add_outputs) == 1
assert child_add_outputs[0].value == 7
- assert session_queue.completed_item_ids == [1]
+ assert session_queue.completed_item_ids == [100, 1]
assert events.errors == []
@@ -1332,9 +1503,12 @@ def test_run_rejects_non_exposed_dynamic_workflow_inputs(monkeypatch: pytest.Mon
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
+ session_queue.add_queue_item(queue_item)
runner.run(queue_item=queue_item)
assert session.has_error()
@@ -1364,9 +1538,12 @@ def test_run_fails_call_saved_workflow_when_child_workflow_graph_cannot_be_built
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
+ session_queue.add_queue_item(queue_item)
runner.run(queue_item=queue_item)
assert not session.is_waiting_on_workflow_call()
@@ -1397,9 +1574,12 @@ def test_run_fails_call_saved_workflow_with_invalid_selection_without_entering_w
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
+ session_queue.add_queue_item(queue_item)
runner.run(queue_item=queue_item)
assert not session.is_waiting_on_workflow_call()
@@ -1441,9 +1621,12 @@ def test_run_fails_call_saved_workflow_when_depth_limit_is_exceeded(
"session": session,
"session_id": "session-id",
"user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
},
)()
+ session_queue.add_queue_item(queue_item)
runner.run(queue_item=queue_item)
assert not session.is_waiting_on_workflow_call()
From 45fd0368249ecc78b0666ad69d192f814745fdeb Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 21 Apr 2026 06:44:02 -0500
Subject: [PATCH 051/100] Cascade workflow call cancel and root retry
---
.../session_queue/session_queue_sqlite.py | 91 ++++++--
...st_session_queue_workflow_call_metadata.py | 195 ++++++++++++++++++
2 files changed, 269 insertions(+), 17 deletions(-)
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index f8c82925139..710abcac7a9 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -320,6 +320,47 @@ def _set_queue_item_status(
self.__invoker.services.events.emit_queue_item_status_changed(queue_item, batch_status, queue_status)
return queue_item
+ def _get_workflow_call_child_ids(self, item_id: int) -> list[int]:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT item_id
+ FROM session_queue
+ WHERE parent_item_id = ?
+ ORDER BY item_id ASC
+ """,
+ (item_id,),
+ )
+ rows = cast(list[sqlite3.Row], cursor.fetchall())
+ return [row[0] for row in rows]
+
+ def _get_workflow_call_descendant_ids(self, item_id: int) -> list[int]:
+ descendant_ids: list[int] = []
+ queue: list[int] = [item_id]
+ while queue:
+ current_item_id = queue.pop(0)
+ child_ids = self._get_workflow_call_child_ids(current_item_id)
+ descendant_ids.extend(child_ids)
+ queue.extend(child_ids)
+ return descendant_ids
+
+ def _get_workflow_call_ancestor_ids(self, item_id: int) -> list[int]:
+ ancestor_ids: list[int] = []
+ current_queue_item = self.get_queue_item(item_id)
+ while current_queue_item.parent_item_id is not None:
+ parent_item_id = current_queue_item.parent_item_id
+ ancestor_ids.append(parent_item_id)
+ current_queue_item = self.get_queue_item(parent_item_id)
+ return ancestor_ids
+
+ def _get_workflow_call_chain_item_ids(self, item_id: int) -> list[int]:
+ ancestor_ids = self._get_workflow_call_ancestor_ids(item_id)
+ root_item_id = ancestor_ids[-1] if ancestor_ids else item_id
+ descendant_ids = self._get_workflow_call_descendant_ids(root_item_id)
+ chain_item_ids = ancestor_ids + [item_id] + descendant_ids
+ deduped_chain_item_ids = list(dict.fromkeys(chain_item_ids))
+ return deduped_chain_item_ids
+
def is_empty(self, queue_id: str) -> IsEmptyResult:
with self._db.transaction() as cursor:
cursor.execute(
@@ -415,8 +456,10 @@ def prune(self, queue_id: str, user_id: Optional[str] = None) -> PruneResult:
return PruneResult(deleted=count)
def cancel_queue_item(self, item_id: int) -> SessionQueueItem:
- queue_item = self._set_queue_item_status(item_id=item_id, status="canceled")
- return queue_item
+ chain_item_ids = self._get_workflow_call_chain_item_ids(item_id)
+ for chain_item_id in chain_item_ids:
+ self._set_queue_item_status(item_id=chain_item_id, status="canceled")
+ return self.get_queue_item(item_id)
def delete_queue_item(self, item_id: int) -> None:
"""Deletes a session queue item"""
@@ -1026,7 +1069,8 @@ def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsRes
"""Retries the given queue items"""
with self._db.transaction() as cursor:
values_to_insert: list[ValueToInsertTuple] = []
- retried_item_ids: list[int] = []
+ retried_root_item_ids: list[int] = []
+ seen_root_item_ids: set[int] = set()
for item_id in item_ids:
queue_item = self.get_queue_item(item_id)
@@ -1034,35 +1078,48 @@ def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsRes
if queue_item.status not in ("failed", "canceled"):
continue
- retried_item_ids.append(item_id)
+ root_item_id = queue_item.root_item_id or queue_item.item_id
+ if root_item_id in seen_root_item_ids:
+ continue
+ seen_root_item_ids.add(root_item_id)
+
+ root_queue_item = self.get_queue_item(root_item_id)
+ if root_queue_item.status not in ("failed", "canceled"):
+ continue
+
+ retried_root_item_ids.append(root_item_id)
field_values_json = (
- json.dumps(queue_item.field_values, default=to_jsonable_python) if queue_item.field_values else None
+ json.dumps(root_queue_item.field_values, default=to_jsonable_python)
+ if root_queue_item.field_values
+ else None
)
workflow_json = (
- json.dumps(queue_item.workflow, default=to_jsonable_python) if queue_item.workflow else None
+ json.dumps(root_queue_item.workflow, default=to_jsonable_python)
+ if root_queue_item.workflow
+ else None
)
- cloned_session = GraphExecutionState(graph=queue_item.session.graph)
+ cloned_session = GraphExecutionState(graph=root_queue_item.session.graph)
cloned_session_json = cloned_session.model_dump_json(warnings=False, exclude_none=True)
retried_from_item_id = (
- queue_item.retried_from_item_id
- if queue_item.retried_from_item_id is not None
- else queue_item.item_id
+ root_queue_item.retried_from_item_id
+ if root_queue_item.retried_from_item_id is not None
+ else root_queue_item.item_id
)
value_to_insert: ValueToInsertTuple = (
- queue_item.queue_id,
+ root_queue_item.queue_id,
cloned_session_json,
cloned_session.id,
- queue_item.batch_id,
+ root_queue_item.batch_id,
field_values_json,
- queue_item.priority,
+ root_queue_item.priority,
workflow_json,
- queue_item.origin,
- queue_item.destination,
+ root_queue_item.origin,
+ root_queue_item.destination,
retried_from_item_id,
- queue_item.user_id,
+ root_queue_item.user_id,
)
values_to_insert.append(value_to_insert)
@@ -1078,7 +1135,7 @@ def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsRes
retry_result = RetryItemsResult(
queue_id=queue_id,
- retried_item_ids=retried_item_ids,
+ retried_item_ids=retried_root_item_ids,
)
self.__invoker.services.events.emit_queue_items_retried(retry_result)
return retry_result
diff --git a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
index 5007d6f4d67..f57d4d8a834 100644
--- a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
+++ b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
@@ -178,3 +178,198 @@ def test_get_queue_status_counts_waiting_items(session_queue: SqliteSessionQueue
assert queue_status.waiting == 1
assert queue_status.total == 1
+
+
+def test_cancel_queue_item_cascades_from_waiting_parent_to_child_chain(session_queue: SqliteSessionQueue) -> None:
+ parent_session = GraphExecutionState(graph=Graph())
+ child_session = GraphExecutionState(graph=Graph())
+ grandchild_session = GraphExecutionState(graph=Graph())
+
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id, session, session_id, batch_id, priority, user_id, status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ parent_session.model_dump_json(warnings=False),
+ parent_session.id,
+ str(uuid.uuid4()),
+ 0,
+ "user-1",
+ "waiting",
+ ),
+ )
+ parent_item_id = cursor.lastrowid
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id, session, session_id, batch_id, priority, user_id, status,
+ workflow_call_id, parent_item_id, parent_session_id, root_item_id, workflow_call_depth
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ child_session.model_dump_json(warnings=False),
+ child_session.id,
+ str(uuid.uuid4()),
+ 0,
+ "user-1",
+ "waiting",
+ "workflow-call-1",
+ parent_item_id,
+ parent_session.id,
+ parent_item_id,
+ 1,
+ ),
+ )
+ child_item_id = cursor.lastrowid
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id, session, session_id, batch_id, priority, user_id, status,
+ workflow_call_id, parent_item_id, parent_session_id, root_item_id, workflow_call_depth
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ grandchild_session.model_dump_json(warnings=False),
+ grandchild_session.id,
+ str(uuid.uuid4()),
+ 0,
+ "user-1",
+ "pending",
+ "workflow-call-2",
+ child_item_id,
+ child_session.id,
+ parent_item_id,
+ 2,
+ ),
+ )
+ grandchild_item_id = cursor.lastrowid
+
+ session_queue.cancel_queue_item(parent_item_id)
+
+ assert session_queue.get_queue_item(parent_item_id).status == "canceled"
+ assert session_queue.get_queue_item(child_item_id).status == "canceled"
+ assert session_queue.get_queue_item(grandchild_item_id).status == "canceled"
+
+
+def test_cancel_queue_item_cascades_from_child_to_waiting_parents(session_queue: SqliteSessionQueue) -> None:
+ parent_session = GraphExecutionState(graph=Graph())
+ child_session = GraphExecutionState(graph=Graph())
+
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id, session, session_id, batch_id, priority, user_id, status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ parent_session.model_dump_json(warnings=False),
+ parent_session.id,
+ str(uuid.uuid4()),
+ 0,
+ "user-1",
+ "waiting",
+ ),
+ )
+ parent_item_id = cursor.lastrowid
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id, session, session_id, batch_id, priority, user_id, status,
+ workflow_call_id, parent_item_id, parent_session_id, root_item_id, workflow_call_depth
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ child_session.model_dump_json(warnings=False),
+ child_session.id,
+ str(uuid.uuid4()),
+ 0,
+ "user-1",
+ "pending",
+ "workflow-call-1",
+ parent_item_id,
+ parent_session.id,
+ parent_item_id,
+ 1,
+ ),
+ )
+ child_item_id = cursor.lastrowid
+
+ session_queue.cancel_queue_item(child_item_id)
+
+ assert session_queue.get_queue_item(child_item_id).status == "canceled"
+ assert session_queue.get_queue_item(parent_item_id).status == "canceled"
+
+
+def test_retry_items_by_id_retries_root_once_for_child_chain_item(session_queue: SqliteSessionQueue) -> None:
+ root_session = GraphExecutionState(graph=Graph())
+ child_session = GraphExecutionState(graph=Graph())
+
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id, session, session_id, batch_id, priority, user_id, status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ root_session.model_dump_json(warnings=False),
+ root_session.id,
+ str(uuid.uuid4()),
+ 0,
+ "user-1",
+ "failed",
+ ),
+ )
+ root_item_id = cursor.lastrowid
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id, session, session_id, batch_id, priority, user_id, status,
+ workflow_call_id, parent_item_id, parent_session_id, root_item_id, workflow_call_depth
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ child_session.model_dump_json(warnings=False),
+ child_session.id,
+ str(uuid.uuid4()),
+ 0,
+ "user-1",
+ "failed",
+ "workflow-call-1",
+ root_item_id,
+ root_session.id,
+ root_item_id,
+ 1,
+ ),
+ )
+ child_item_id = cursor.lastrowid
+
+ retry_result = session_queue.retry_items_by_id("default", [child_item_id, root_item_id])
+
+ assert retry_result.retried_item_ids == [root_item_id]
+
+ all_items = session_queue.list_all_queue_items("default")
+ retried_items = [item for item in all_items if item.retried_from_item_id == root_item_id]
+ assert len(retried_items) == 1
+ assert retried_items[0].status == "pending"
+ assert retried_items[0].workflow_call_id is None
+ assert retried_items[0].parent_item_id is None
+ assert retried_items[0].root_item_id is None
From e73961d061e34af604beaf1d07bce32d5b1da46a Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 21 Apr 2026 09:11:42 -0500
Subject: [PATCH 052/100] Refine workflow call queue UI and docs
---
docs/contributing/call_saved_workflow.md | 31 +++-
invokeai/app/services/shared/README.md | 5 +
invokeai/frontend/web/public/locales/en.json | 1 +
.../QueueList/QueueItemComponent.tsx | 6 +-
.../components/QueueList/QueueItemDetail.tsx | 10 +-
.../getQueueItemActionVisibility.test.ts | 64 ++++++++
.../QueueList/getQueueItemActionVisibility.ts | 9 ++
.../components/common/QueueStatusBadge.tsx | 1 +
.../web/src/services/api/run-graph.ts | 8 +-
.../frontend/web/src/services/api/schema.ts | 141 +++++++++++++++++-
.../test_session_processor_shutdown.py | 50 ++++++-
11 files changed, 311 insertions(+), 15 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/queue/components/QueueList/getQueueItemActionVisibility.test.ts
create mode 100644 invokeai/frontend/web/src/features/queue/components/QueueList/getQueueItemActionVisibility.ts
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index f2afab62120..307b241cbe1 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -99,6 +99,16 @@ Implemented runtime scaffolding:
- literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node
- connected dynamic values are accepted as special call-boundary edges and are resolved from parent results at runtime
- both are validated against the child workflow's exposed form interface before being applied to the child graph
+- Queue lifecycle semantics now exist for workflow-call chains:
+ - parent queue items are suspended in `waiting` while a child queue row runs
+ - child success resumes the suspended parent and completes the parent call node with the child `workflow_return`
+ - child failure fails the suspended parent and cascades upward through any waiting parent chain
+ - canceling a parent cancels its descendant child chain
+ - canceling a child cancels the waiting parent chain upward
+ - retry is root-oriented rather than child-oriented; child queue rows should not be directly retried from the UI
+ - the current UI policy is:
+ - child queue rows keep `Cancel`
+ - child queue rows hide `Retry`
Implemented conversion helper:
@@ -223,6 +233,25 @@ Current limitation:
- the current implementation is still an intermediate architecture step, but it is now materially closer to the
intended durable parent/child model than the earlier inline-runner path
+### 4a. Queue Lifecycle Contract
+
+The current queue-visible implementation uses the following lifecycle contract:
+
+- root or parent queue items may enter `waiting` while suspended on a child workflow call
+- child workflow executions are represented as real queue rows with explicit parent/child relationship metadata
+- child completion resumes the suspended parent and returns control to normal queue execution
+- child failure fails the suspended parent call node and cascades upward through any ancestor chain
+- cancel operations are chain-aware:
+ - canceling a waiting parent cancels descendants
+ - canceling a child cancels waiting ancestors
+- retry operations are root-aware:
+ - retrying a root queue item creates a new root execution
+ - retrying a child queue item should be normalized to the root by backend code
+ - child queue rows should not expose direct retry affordances in the UI
+
+This is now part of the intended user-facing contract, even though the orchestration still lives in
+`WorkflowCallCoordinator`.
+
### 5. Return Values
Return values should be explicit.
@@ -398,9 +427,9 @@ Still needed in later increments:
- parent-child resume behavior once child execution is no longer an inline runner detail
- child failure propagation into parent failure
- nested runtime execution beyond a single attached child state
-- eventual queue/session persistence rules if child executions become first-class queue items
- eventual support for child workflows that contain batch-special nodes, once child execution is run through the proper
batch/session path
+- eventual migration from coordinator-owned resume/fail behavior to a more general scheduler or queue-lifecycle model
## Recommended Immediate Next Step
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index c712986a8e0..fc9488a622e 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -146,6 +146,11 @@ Workflow-call note:
the higher-level scheduler semantics are still evolving.
- The `session_queue` schema now has matching columns for those relationship fields, and parent queue items can enter a
`waiting` status while suspended on a child workflow execution.
+- Queue lifecycle semantics are now partially defined for workflow-call chains:
+ - child success resumes the waiting parent
+ - child failure fails the waiting parent and can cascade upward through ancestors
+ - cancelation is chain-aware across parents and children
+ - retry is root-oriented and should not be exposed directly on child queue rows in the UI
- This is still an intermediate architecture step and should eventually be replaced by a more general parent/child
execution mechanism rather than coordinator-specific resume/fail behavior.
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 498478c3126..210ec85c9fd 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -432,6 +432,7 @@
"credits": "Credits",
"pending": "Pending",
"in_progress": "In Progress",
+ "waiting": "Waiting On Child Workflow",
"paused": "Paused",
"completed": "Completed",
"failed": "Failed",
diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx
index e1c5f4ec973..8add8c00145 100644
--- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx
@@ -15,6 +15,7 @@ import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi';
import type { S } from 'services/api/types';
import { COLUMN_WIDTHS, SYSTEM_USER_ID } from './constants';
+import { getQueueItemActionVisibility } from './getQueueItemActionVisibility';
import QueueItemDetail from './QueueItemDetail';
const selectedStyles = { bg: 'base.700' };
@@ -97,6 +98,7 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => {
const isCanceled = useMemo(() => ['canceled', 'completed', 'failed'].includes(item.status), [item.status]);
const isFailed = useMemo(() => ['canceled', 'failed'].includes(item.status), [item.status]);
+ const { canShowCancelQueueItem, canShowRetryQueueItem } = useMemo(() => getQueueItemActionVisibility(item), [item]);
const originText = useOriginText(item.origin);
const destinationText = useDestinationText(item.destination);
@@ -183,7 +185,7 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => {
- {!isFailed && (
+ {canShowCancelQueueItem && !isFailed && (
{
icon={}
/>
)}
- {isFailed && (
+ {canShowRetryQueueItem && isFailed && (
{
);
const isFailed = useMemo(() => !!queueItem && ['canceled', 'failed'].includes(queueItem.status), [queueItem]);
+ const { canShowCancelQueueItem, canShowRetryQueueItem } = useMemo(
+ () => getQueueItemActionVisibility(queueItemDTO),
+ [queueItemDTO]
+ );
const onCancelBatch = useCallback(() => {
cancelBatch.trigger(batch_id);
@@ -82,7 +88,7 @@ const QueueItemComponent = ({ queueItem: queueItemDTO }: Props) => {
- {!isFailed && (
+ {canShowCancelQueueItem && !isFailed && (
)}
- {isFailed && (
+ {canShowRetryQueueItem && isFailed && (
) : (
{statusLabel ?? t('nodes.savedWorkflowChoose')}
)}
- {selectionState.status === 'selected' && compatibilityState?.message && (
+ {selectionState.status === 'selected' && displayState.compatibilityMessage && (
- {compatibilityState.message}
+ {displayState.compatibilityMessage}
)}
{isFetching && (
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
index c3e5477de09..2f0647b2cac 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
buildSavedWorkflowOptions,
+ getSavedWorkflowDisplayState,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
MISSING_WORKFLOW_OPTION_VALUE,
@@ -78,4 +79,31 @@ describe('savedWorkflowFieldUtils', () => {
value: MISSING_WORKFLOW_OPTION_VALUE,
});
});
+
+ it('builds display state for an already-selected unsupported workflow', () => {
+ const selectionState = getSavedWorkflowSelectionState(workflows, 'workflow-b');
+
+ expect(getSavedWorkflowDisplayState(selectionState)).toEqual({
+ selection: 'selected',
+ statusLabelKey: null,
+ badges: ['unsupported', 'default'],
+ compatibilityMessage: 'The workflow must contain exactly one workflow_return node.',
+ });
+ });
+
+ it('builds display state for missing and unselected workflows', () => {
+ expect(getSavedWorkflowDisplayState({ status: 'unselected' })).toEqual({
+ selection: 'unselected',
+ statusLabelKey: 'nodes.savedWorkflowChoose',
+ badges: [],
+ compatibilityMessage: null,
+ });
+
+ expect(getSavedWorkflowDisplayState({ status: 'missing', workflowId: 'missing-workflow' })).toEqual({
+ selection: 'missing',
+ statusLabelKey: 'nodes.savedWorkflowMissing',
+ badges: [],
+ compatibilityMessage: null,
+ });
+ });
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index d604421053d..0947cc05ba7 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -1,7 +1,9 @@
import type { ComboboxOption } from '@invoke-ai/ui-library';
+import { getWorkflowCallCompatibilityState } from 'features/workflowLibrary/util/workflowCallCompatibility';
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
+export type SavedWorkflowBadge = 'unsupported' | 'default' | 'shared';
type SavedWorkflowSelectionState =
| { status: 'unselected' }
@@ -49,3 +51,57 @@ export const getSavedWorkflowSelectionOption = (selectionState: SavedWorkflowSel
value: MISSING_WORKFLOW_OPTION_VALUE,
};
};
+
+export type SavedWorkflowDisplayState =
+ | {
+ selection: 'unselected' | 'missing';
+ statusLabelKey: 'nodes.savedWorkflowChoose' | 'nodes.savedWorkflowMissing';
+ badges: SavedWorkflowBadge[];
+ compatibilityMessage: null;
+ }
+ | {
+ selection: 'selected';
+ statusLabelKey: null;
+ badges: SavedWorkflowBadge[];
+ compatibilityMessage: string | null;
+ };
+
+export const getSavedWorkflowDisplayState = (
+ selectionState: SavedWorkflowSelectionState
+): SavedWorkflowDisplayState => {
+ if (selectionState.status === 'unselected') {
+ return {
+ selection: 'unselected',
+ statusLabelKey: 'nodes.savedWorkflowChoose',
+ badges: [],
+ compatibilityMessage: null,
+ };
+ }
+
+ if (selectionState.status === 'missing') {
+ return {
+ selection: 'missing',
+ statusLabelKey: 'nodes.savedWorkflowMissing',
+ badges: [],
+ compatibilityMessage: null,
+ };
+ }
+
+ const compatibilityState = getWorkflowCallCompatibilityState(selectionState.workflow);
+ const badges: SavedWorkflowBadge[] = [];
+ if (compatibilityState.isUnsupported) {
+ badges.push('unsupported');
+ }
+ if (selectionState.workflow.category === 'default') {
+ badges.push('default');
+ } else if (selectionState.workflow.is_public) {
+ badges.push('shared');
+ }
+
+ return {
+ selection: 'selected',
+ statusLabelKey: null,
+ badges,
+ compatibilityMessage: compatibilityState.message,
+ };
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
index dea27103e69..5c8dfd5dd45 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
@@ -5,7 +5,7 @@ import { selectCurrentUser } from 'features/auth/store/authSlice';
import { selectWorkflowId } from 'features/nodes/store/selectors';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
-import { getWorkflowCallCompatibilityState } from 'features/workflowLibrary/util/workflowCallCompatibility';
+import { getWorkflowLibraryListItemState } from 'features/workflowLibrary/util/workflowLibraryListItemState';
import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { type ChangeEvent, memo, type MouseEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -66,7 +66,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
}, [workflow.tags]);
- const compatibilityState = useMemo(() => getWorkflowCallCompatibilityState(workflow), [workflow]);
+ const listItemState = useMemo(() => getWorkflowLibraryListItemState(workflow), [workflow]);
const handleClickLoad = useCallback(() => {
loadWorkflowWithDialog({
@@ -121,7 +121,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
{t('workflows.opened')}
)}
- {setupStatus?.multiuser_enabled && workflow.is_public && workflow.category !== 'default' && (
+ {setupStatus?.multiuser_enabled && listItemState.showSharedBadge && (
)}
- {compatibilityState.isUnsupported && (
-
+ {listItemState.showUnsupportedBadge && (
+
)}
- {workflow.category === 'default' && (
+ {listItemState.showDefaultIcon && (
{workflow.description}
- {compatibilityState.isUnsupported && (
+ {listItemState.showUnsupportedBadge && (
- {compatibilityState.message ?? t('workflows.savedWorkflowUnsupportedDescription')}
+ {listItemState.unsupportedMessage ?? t('workflows.savedWorkflowUnsupportedDescription')}
)}
{tags.length > 0 && (
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowCallCompatibility.ts b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowCallCompatibility.ts
index 3b515cd5212..ec7cf75bbeb 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowCallCompatibility.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowCallCompatibility.ts
@@ -23,6 +23,6 @@ export const getWorkflowCallCompatibilityState = (
return {
isUnsupported: true,
- message: compatibility.message,
+ message: compatibility.message ?? null,
};
};
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.test.ts b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.test.ts
new file mode 100644
index 00000000000..1706c391dd3
--- /dev/null
+++ b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+
+import { getWorkflowLibraryListItemState } from './workflowLibraryListItemState';
+
+describe('workflowLibraryListItemState', () => {
+ it('marks unsupported workflows with their backend reason', () => {
+ expect(
+ getWorkflowLibraryListItemState({
+ category: 'user',
+ is_public: false,
+ call_saved_workflow_compatibility: {
+ is_callable: false,
+ reason: 'missing_workflow_return',
+ message: 'The workflow must contain exactly one workflow_return node.',
+ },
+ })
+ ).toEqual({
+ showUnsupportedBadge: true,
+ unsupportedMessage: 'The workflow must contain exactly one workflow_return node.',
+ showSharedBadge: false,
+ showDefaultIcon: false,
+ });
+ });
+
+ it('marks shared non-default workflows and leaves callable workflows supported', () => {
+ expect(
+ getWorkflowLibraryListItemState({
+ category: 'user',
+ is_public: true,
+ call_saved_workflow_compatibility: {
+ is_callable: true,
+ reason: 'ok',
+ message: null,
+ },
+ })
+ ).toEqual({
+ showUnsupportedBadge: false,
+ unsupportedMessage: null,
+ showSharedBadge: true,
+ showDefaultIcon: false,
+ });
+ });
+
+ it('marks default workflows with the default icon instead of the shared badge', () => {
+ expect(
+ getWorkflowLibraryListItemState({
+ category: 'default',
+ is_public: true,
+ call_saved_workflow_compatibility: {
+ is_callable: true,
+ reason: 'ok',
+ message: null,
+ },
+ })
+ ).toEqual({
+ showUnsupportedBadge: false,
+ unsupportedMessage: null,
+ showSharedBadge: false,
+ showDefaultIcon: true,
+ });
+ });
+});
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts
new file mode 100644
index 00000000000..943ef67d97b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts
@@ -0,0 +1,22 @@
+import { getWorkflowCallCompatibilityState } from 'features/workflowLibrary/util/workflowCallCompatibility';
+import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
+
+export type WorkflowLibraryListItemState = {
+ showUnsupportedBadge: boolean;
+ unsupportedMessage: string | null;
+ showSharedBadge: boolean;
+ showDefaultIcon: boolean;
+};
+
+export const getWorkflowLibraryListItemState = (
+ workflow: Pick
+): WorkflowLibraryListItemState => {
+ const compatibilityState = getWorkflowCallCompatibilityState(workflow);
+
+ return {
+ showUnsupportedBadge: compatibilityState.isUnsupported,
+ unsupportedMessage: compatibilityState.message,
+ showSharedBadge: workflow.is_public && workflow.category !== 'default',
+ showDefaultIcon: workflow.category === 'default',
+ };
+};
From 896e50c25332acc736c09d8c9f2c67c787a3eb77 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Thu, 23 Apr 2026 14:09:50 -0500
Subject: [PATCH 070/100] Treat waiting queue items as nonterminal in staging
area
---
.../components/StagingArea/state.ts | 2 ++
.../common/QueueStatusBadge.test.ts | 23 +++++++++++++++++++
.../components/common/QueueStatusBadge.tsx | 7 ++++--
3 files changed, 30 insertions(+), 2 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.test.ts
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts
index 6c16a8fdf22..818b93a648e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts
@@ -71,6 +71,8 @@ const getQueueItemStatusRank = (status: S['SessionQueueItem']['status']): number
case 'pending':
return 0;
case 'in_progress':
+ // Waiting items are suspended on child workflow execution, but they are still nonterminal.
+ case 'waiting':
return 1;
case 'completed':
case 'failed':
diff --git a/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.test.ts b/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.test.ts
new file mode 100644
index 00000000000..7eb2e0fe088
--- /dev/null
+++ b/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { getQueueStatusBadgeState } from './QueueStatusBadge';
+
+describe('QueueStatusBadge', () => {
+ it('maps waiting status to the waiting translation key and color', () => {
+ expect(getQueueStatusBadgeState('waiting')).toEqual({
+ colorScheme: 'purple',
+ translationKey: 'queue.waiting',
+ });
+ });
+
+ it('keeps existing retry-terminal statuses distinct', () => {
+ expect(getQueueStatusBadgeState('failed')).toEqual({
+ colorScheme: 'red',
+ translationKey: 'queue.failed',
+ });
+ expect(getQueueStatusBadgeState('canceled')).toEqual({
+ colorScheme: 'orange',
+ translationKey: 'queue.canceled',
+ });
+ });
+});
diff --git a/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.tsx b/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.tsx
index d7043046173..ce26b7e9065 100644
--- a/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.tsx
@@ -3,7 +3,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { SessionQueueItemStatus } from 'services/api/endpoints/queue';
-const STATUSES = {
+export const QUEUE_STATUS_BADGE_STATES = {
pending: { colorScheme: 'cyan', translationKey: 'queue.pending' },
in_progress: { colorScheme: 'yellow', translationKey: 'queue.in_progress' },
waiting: { colorScheme: 'purple', translationKey: 'queue.waiting' },
@@ -12,8 +12,11 @@ const STATUSES = {
canceled: { colorScheme: 'orange', translationKey: 'queue.canceled' },
};
+export const getQueueStatusBadgeState = (status: SessionQueueItemStatus) => QUEUE_STATUS_BADGE_STATES[status];
+
const StatusBadge = ({ status }: { status: SessionQueueItemStatus }) => {
const { t } = useTranslation();
- return {t(STATUSES[status].translationKey)};
+ const badgeState = getQueueStatusBadgeState(status);
+ return {t(badgeState.translationKey)};
};
export default memo(StatusBadge);
From 74ab0ea0d603270d0c12f3d23684c84afbaaf164 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Mon, 27 Apr 2026 16:42:39 -0500
Subject: [PATCH 071/100] chore: typegen
---
.../frontend/web/src/services/api/schema.ts | 1013 ++++++++++++++---
1 file changed, 875 insertions(+), 138 deletions(-)
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index fa7e82632a3..0a13be97b76 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -3002,11 +3002,6 @@ export type components = {
* @default null
*/
latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description Noise tensor
- * @default null
- */
- noise?: components["schemas"]["LatentsField"] | null;
/**
* @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
* @default null
@@ -3744,6 +3739,11 @@ export type components = {
* @description Number of queue items with status 'in_progress'
*/
in_progress: number;
+ /**
+ * Waiting
+ * @description Number of queue items with status 'waiting'
+ */
+ waiting: number;
/**
* Completed
* @description Number of queue items with status 'complete'
@@ -4694,6 +4694,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -4775,6 +4780,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -4952,6 +4962,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -5255,6 +5270,49 @@ export type components = {
*/
type: "calculate_image_tiles_output";
};
+ /**
+ * Call Saved Workflow
+ * @description Displays and later executes against a selected saved workflow.
+ */
+ CallSavedWorkflowInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default false
+ */
+ use_cache?: boolean;
+ /**
+ * Workflow Id
+ * @description The selected saved workflow ID, managed by the workflow editor UI.
+ * @default
+ */
+ workflow_id?: string;
+ /**
+ * Workflow Inputs
+ * @description Literal values for the selected workflow's exposed inputs, managed by the workflow editor UI.
+ * @default {}
+ */
+ workflow_inputs?: {
+ [key: string]: unknown;
+ };
+ /**
+ * type
+ * @default call_saved_workflow
+ * @constant
+ */
+ type: "call_saved_workflow";
+ };
/**
* CancelAllExceptCurrentResult
* @description Result of canceling all except current
@@ -5651,11 +5709,6 @@ export type components = {
* @default null
*/
latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description Noise tensor
- * @default null
- */
- noise?: components["schemas"]["LatentsField"] | null;
/**
* @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
* @default null
@@ -6501,6 +6554,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -6724,6 +6782,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -6798,6 +6861,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -6872,6 +6940,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -6946,6 +7019,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -7030,6 +7108,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -7104,6 +7187,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -7175,6 +7263,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -7246,6 +7339,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -7317,6 +7415,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -8992,6 +9095,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -9321,6 +9429,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -10003,11 +10116,6 @@ export type components = {
* @default null
*/
latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description Noise tensor
- * @default null
- */
- noise?: components["schemas"]["LatentsField"] | null;
/**
* @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
* @default null
@@ -10744,11 +10852,6 @@ export type components = {
* @default null
*/
latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description Noise tensor
- * @default null
- */
- noise?: components["schemas"]["LatentsField"] | null;
/**
* @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
* @default null
@@ -10943,11 +11046,6 @@ export type components = {
* @default null
*/
latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description Noise tensor
- * @default null
- */
- noise?: components["schemas"]["LatentsField"] | null;
/**
* @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
* @default null
@@ -12089,7 +12187,7 @@ export type components = {
* @description The nodes in this graph
*/
nodes?: {
- [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UniversalNoiseInvocation"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
};
/**
* Edges
@@ -12126,7 +12224,7 @@ export type components = {
* @description The results of node executions
*/
results: {
- [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["UniversalNoiseOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
+ [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
};
/**
* Errors
@@ -12135,6 +12233,30 @@ export type components = {
errors: {
[key: string]: string;
};
+ /**
+ * Workflow Call Stack
+ * @description The nested workflow call stack inherited by this execution state.
+ */
+ workflow_call_stack: components["schemas"]["WorkflowCallFrame"][];
+ /**
+ * Workflow Call History
+ * @description Completed or failed workflow-call relationships observed by this execution state.
+ */
+ workflow_call_history: components["schemas"]["WorkflowCallExecution"][];
+ /** @description Parent workflow-call relationship metadata when this execution state is a child workflow session. */
+ workflow_call_parent?: components["schemas"]["WorkflowCallParentRef"] | null;
+ /** @description The child workflow call this execution state is currently waiting on, if any. */
+ waiting_workflow_call?: components["schemas"]["WorkflowCallFrame"] | null;
+ /** @description The active workflow-call relationship metadata for the current waiting child workflow, if any. */
+ waiting_workflow_call_execution?: components["schemas"]["WorkflowCallExecution"] | null;
+ /** @description The child workflow execution state spawned by the current waiting workflow call, if any. */
+ waiting_workflow_call_child_session?: components["schemas"]["GraphExecutionState"] | null;
+ /**
+ * Max Workflow Call Depth
+ * @description The maximum permitted workflow call depth for nested workflow execution.
+ * @default 4
+ */
+ max_workflow_call_depth?: number;
/**
* Prepared Source Mapping
* @description The map of prepared nodes to original graph nodes
@@ -12658,6 +12780,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -12726,6 +12853,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -12794,6 +12926,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -12862,6 +12999,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -12930,6 +13072,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -13000,6 +13147,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -13070,6 +13222,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -15446,7 +15603,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UniversalNoiseInvocation"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -15456,7 +15613,7 @@ export type components = {
* Result
* @description The result of the invocation
*/
- result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["UniversalNoiseOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
+ result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
};
/**
* InvocationErrorEvent
@@ -15510,7 +15667,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UniversalNoiseInvocation"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -15551,6 +15708,7 @@ export type components = {
calculate_image_tiles: components["schemas"]["CalculateImageTilesOutput"];
calculate_image_tiles_even_split: components["schemas"]["CalculateImageTilesOutput"];
calculate_image_tiles_min_overlap: components["schemas"]["CalculateImageTilesOutput"];
+ call_saved_workflow: components["schemas"]["WorkflowReturnOutput"];
canny_edge_detection: components["schemas"]["ImageOutput"];
canvas_output: components["schemas"]["ImageOutput"];
canvas_paste_back: components["schemas"]["ImageOutput"];
@@ -15771,9 +15929,9 @@ export type components = {
tile_to_properties: components["schemas"]["TileToPropertiesOutput"];
tiled_multi_diffusion_denoise_latents: components["schemas"]["LatentsOutput"];
tomask: components["schemas"]["ImageOutput"];
- universal_noise: components["schemas"]["UniversalNoiseOutput"];
unsharp_mask: components["schemas"]["ImageOutput"];
vae_loader: components["schemas"]["VAEOutput"];
+ workflow_return: components["schemas"]["WorkflowReturnOutput"];
z_image_control: components["schemas"]["ZImageControlOutput"];
z_image_denoise: components["schemas"]["LatentsOutput"];
z_image_denoise_meta: components["schemas"]["LatentsMetaOutput"];
@@ -15837,7 +15995,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UniversalNoiseInvocation"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -15912,7 +16070,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UniversalNoiseInvocation"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -17459,6 +17617,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -17762,6 +17925,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -17840,6 +18008,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -17916,6 +18089,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -17991,6 +18169,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18066,6 +18249,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18144,6 +18332,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18223,6 +18416,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18298,6 +18496,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18376,6 +18579,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18455,6 +18663,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18530,6 +18743,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18605,6 +18823,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18680,6 +18903,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18758,6 +18986,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18834,6 +19067,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -18909,6 +19147,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19254,6 +19497,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19341,6 +19589,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19424,6 +19677,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19508,6 +19766,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19589,6 +19852,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19671,6 +19939,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19753,6 +20026,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19835,6 +20113,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -19920,6 +20203,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20001,6 +20289,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20081,6 +20374,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20162,6 +20460,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20243,6 +20546,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20321,6 +20629,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20400,6 +20713,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20479,6 +20797,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20563,6 +20886,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20642,6 +20970,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20724,6 +21057,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20805,6 +21143,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20889,6 +21232,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -20973,6 +21321,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -21057,6 +21410,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -23246,6 +23604,11 @@ export type components = {
* @description metadata from remote source
*/
source_api_response?: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page)
+ */
+ source_url?: string | null;
/**
* Name
* @description Name of the model.
@@ -24341,7 +24704,7 @@ export type components = {
* @description The new status of the queue item
* @enum {string}
*/
- status: "pending" | "in_progress" | "completed" | "failed" | "canceled";
+ status: "pending" | "in_progress" | "waiting" | "completed" | "failed" | "canceled";
/**
* Status Sequence
* @description A monotonically increasing version for this queue item's visible status lifecycle
@@ -24418,6 +24781,18 @@ export type components = {
* @description The IDs of the queue items that were retried
*/
retried_item_ids: number[];
+ /**
+ * User Ids
+ * @description The IDs of the users who own the retried root queue items
+ */
+ user_ids: string[];
+ /**
+ * Retried Item Ids By User
+ * @description The retried root queue item IDs keyed by owner user ID.
+ */
+ retried_item_ids_by_user: {
+ [key: string]: number[];
+ };
};
/**
* Qwen3EncoderField
@@ -24481,6 +24856,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -24564,6 +24944,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -24650,6 +25035,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -25933,11 +26323,6 @@ export type components = {
* @default null
*/
latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description Noise tensor
- * @default null
- */
- noise?: components["schemas"]["LatentsField"] | null;
/**
* @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
* @default null
@@ -27018,6 +27403,11 @@ export type components = {
* @description Number of queue items with status 'in_progress' for the destination
*/
in_progress: number;
+ /**
+ * Waiting
+ * @description Number of queue items with status 'waiting' for the destination
+ */
+ waiting: number;
/**
* Completed
* @description Number of queue items with status 'complete' for the destination
@@ -27055,7 +27445,7 @@ export type components = {
* @default pending
* @enum {string}
*/
- status: "pending" | "in_progress" | "completed" | "failed" | "canceled";
+ status: "pending" | "in_progress" | "waiting" | "completed" | "failed" | "canceled";
/**
* Status Sequence
* @description A monotonically increasing version for this queue item's visible status lifecycle
@@ -27153,6 +27543,31 @@ export type components = {
* @description The item_id of the queue item that this item was retried from
*/
retried_from_item_id?: number | null;
+ /**
+ * Workflow Call Id
+ * @description The active workflow-call relationship id when this queue item is a child execution.
+ */
+ workflow_call_id?: string | null;
+ /**
+ * Parent Item Id
+ * @description The parent queue item id when this queue item is a child workflow execution.
+ */
+ parent_item_id?: number | null;
+ /**
+ * Parent Session Id
+ * @description The parent session id when this queue item is a child workflow execution.
+ */
+ parent_session_id?: string | null;
+ /**
+ * Root Item Id
+ * @description The root queue item id for this workflow call chain, if any.
+ */
+ root_item_id?: number | null;
+ /**
+ * Workflow Call Depth
+ * @description The 1-based workflow-call depth for this queue item when it is a child execution.
+ */
+ workflow_call_depth?: number | null;
/** @description The fully-populated session to be executed */
session: components["schemas"]["GraphExecutionState"];
/** @description The workflow associated with this queue item */
@@ -27190,6 +27605,11 @@ export type components = {
* @description Number of queue items with status 'in_progress'
*/
in_progress: number;
+ /**
+ * Waiting
+ * @description Number of queue items with status 'waiting'
+ */
+ waiting: number;
/**
* Completed
* @description Number of queue items with status 'complete'
@@ -27352,6 +27772,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -27554,6 +27979,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28403,6 +28833,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28474,6 +28909,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28560,6 +29000,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28637,6 +29082,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28721,6 +29171,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28789,6 +29244,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28857,6 +29317,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28925,6 +29390,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -28993,6 +29463,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -29061,6 +29536,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -29196,6 +29676,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -29538,7 +30023,7 @@ export type components = {
* used, and the type will be ignored. They are included here for backwards compatibility.
* @enum {string}
*/
- UIType: "SchedulerField" | "AnyField" | "CollectionField" | "CollectionItemField" | "IsIntermediate" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict" | "DEPRECATED_MainModelField" | "DEPRECATED_CogView4MainModelField" | "DEPRECATED_FluxMainModelField" | "DEPRECATED_SD3MainModelField" | "DEPRECATED_SDXLMainModelField" | "DEPRECATED_SDXLRefinerModelField" | "DEPRECATED_ONNXModelField" | "DEPRECATED_VAEModelField" | "DEPRECATED_FluxVAEModelField" | "DEPRECATED_LoRAModelField" | "DEPRECATED_ControlNetModelField" | "DEPRECATED_IPAdapterModelField" | "DEPRECATED_T2IAdapterModelField" | "DEPRECATED_T5EncoderModelField" | "DEPRECATED_CLIPEmbedModelField" | "DEPRECATED_CLIPLEmbedModelField" | "DEPRECATED_CLIPGEmbedModelField" | "DEPRECATED_SpandrelImageToImageModelField" | "DEPRECATED_ControlLoRAModelField" | "DEPRECATED_SigLipModelField" | "DEPRECATED_FluxReduxModelField" | "DEPRECATED_LLaVAModelField" | "DEPRECATED_Imagen3ModelField" | "DEPRECATED_Imagen4ModelField" | "DEPRECATED_ChatGPT4oModelField" | "DEPRECATED_Gemini2_5ModelField" | "DEPRECATED_FluxKontextModelField" | "DEPRECATED_Veo3ModelField" | "DEPRECATED_RunwayModelField";
+ UIType: "SchedulerField" | "AnyField" | "SavedWorkflowField" | "CollectionField" | "CollectionItemField" | "IsIntermediate" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict" | "DEPRECATED_MainModelField" | "DEPRECATED_CogView4MainModelField" | "DEPRECATED_FluxMainModelField" | "DEPRECATED_SD3MainModelField" | "DEPRECATED_SDXLMainModelField" | "DEPRECATED_SDXLRefinerModelField" | "DEPRECATED_ONNXModelField" | "DEPRECATED_VAEModelField" | "DEPRECATED_FluxVAEModelField" | "DEPRECATED_LoRAModelField" | "DEPRECATED_ControlNetModelField" | "DEPRECATED_IPAdapterModelField" | "DEPRECATED_T2IAdapterModelField" | "DEPRECATED_T5EncoderModelField" | "DEPRECATED_CLIPEmbedModelField" | "DEPRECATED_CLIPLEmbedModelField" | "DEPRECATED_CLIPGEmbedModelField" | "DEPRECATED_SpandrelImageToImageModelField" | "DEPRECATED_ControlLoRAModelField" | "DEPRECATED_SigLipModelField" | "DEPRECATED_FluxReduxModelField" | "DEPRECATED_LLaVAModelField" | "DEPRECATED_Imagen3ModelField" | "DEPRECATED_Imagen4ModelField" | "DEPRECATED_ChatGPT4oModelField" | "DEPRECATED_Gemini2_5ModelField" | "DEPRECATED_FluxKontextModelField" | "DEPRECATED_Veo3ModelField" | "DEPRECATED_RunwayModelField";
/** UNetField */
UNetField: {
/** @description Info to load unet submodel */
@@ -29631,95 +30116,13 @@ export type components = {
message: string;
};
/**
- * Universal Noise
- * @description Generate architecture-specific latent noise for supported denoisers.
+ * Unknown_Config
+ * @description Model config for unknown models, used as a fallback when we cannot positively identify a model.
*/
- UniversalNoiseInvocation: {
+ Unknown_Config: {
/**
- * Id
- * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
- */
- id: string;
- /**
- * Is Intermediate
- * @description Whether or not this is an intermediate invocation.
- * @default false
- */
- is_intermediate?: boolean;
- /**
- * Use Cache
- * @description Whether or not to use the cache
- * @default true
- */
- use_cache?: boolean;
- /**
- * Noise Type
- * @description Architecture-specific noise type.
- * @default null
- */
- noise_type?: ("SD" | "FLUX" | "FLUX.2" | "SD3" | "CogView4" | "Z-Image" | "Anima") | null;
- /**
- * Width
- * @description Width of output (px)
- * @default 512
- */
- width?: number;
- /**
- * Height
- * @description Height of output (px)
- * @default 512
- */
- height?: number;
- /**
- * Seed
- * @description Seed for random number generation
- * @default 0
- */
- seed?: number;
- /**
- * Transformer
- * @default null
- */
- transformer?: components["schemas"]["TransformerField"] | null;
- /**
- * type
- * @default universal_noise
- * @constant
- */
- type: "universal_noise";
- };
- /**
- * UniversalNoiseOutput
- * @description Invocation output for universal architecture-specific noise.
- */
- UniversalNoiseOutput: {
- /** @description Noise tensor */
- noise: components["schemas"]["LatentsField"];
- /**
- * Width
- * @description Width of output (px)
- */
- width: number;
- /**
- * Height
- * @description Height of output (px)
- */
- height: number;
- /**
- * type
- * @default universal_noise_output
- * @constant
- */
- type: "universal_noise_output";
- };
- /**
- * Unknown_Config
- * @description Model config for unknown models, used as a fallback when we cannot positively identify a model.
- */
- Unknown_Config: {
- /**
- * Key
- * @description A unique key for this model.
+ * Key
+ * @description A unique key for this model.
*/
key: string;
/**
@@ -29759,6 +30162,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30043,6 +30451,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30116,6 +30529,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30192,6 +30610,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30265,6 +30688,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30338,6 +30766,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30411,6 +30844,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30487,6 +30925,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30557,6 +31000,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30627,6 +31075,11 @@ export type components = {
* @description The original API response from the source, as stringified JSON.
*/
source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
/**
* Cover Image
* @description Url for image to preview model
@@ -30782,11 +31235,218 @@ export type components = {
*/
graph: string | null;
};
+ /** WorkflowCallCompatibility */
+ WorkflowCallCompatibility: {
+ /**
+ * Is Callable
+ * @description Whether the workflow can currently be executed by call_saved_workflow.
+ */
+ is_callable: boolean;
+ /** @description Structured compatibility result. */
+ reason: components["schemas"]["WorkflowCallCompatibilityReason"];
+ /**
+ * Message
+ * @description Human-readable compatibility detail when unavailable.
+ */
+ message?: string | null;
+ };
+ /**
+ * WorkflowCallCompatibilityReason
+ * @enum {string}
+ */
+ WorkflowCallCompatibilityReason: "ok" | "missing_workflow_return" | "multiple_workflow_return" | "unsupported_node" | "unsupported_batch_input" | "invalid_graph" | "invalid_inputs" | "unknown";
+ /**
+ * WorkflowCallExecution
+ * @description Tracks one parent/child workflow-call relationship and its lifecycle.
+ */
+ WorkflowCallExecution: {
+ /**
+ * Id
+ * @description The workflow-call execution id.
+ */
+ id?: string;
+ /**
+ * Parent Session Id
+ * @description The parent graph execution state id.
+ */
+ parent_session_id: string;
+ /**
+ * Child Session Id
+ * @description The child graph execution state id, if any.
+ */
+ child_session_id?: string | null;
+ /**
+ * Prepared Call Node Id
+ * @description The prepared exec node id for the parent call site.
+ */
+ prepared_call_node_id: string;
+ /**
+ * Source Call Node Id
+ * @description The source graph node id for the parent call site.
+ */
+ source_call_node_id: string;
+ /**
+ * Workflow Id
+ * @description The saved workflow being called.
+ */
+ workflow_id: string;
+ /**
+ * Depth
+ * @description The 1-based depth of this call frame.
+ */
+ depth: number;
+ /**
+ * Status
+ * @description The current workflow-call lifecycle state.
+ * @enum {string}
+ */
+ status: "waiting_for_child" | "running_child" | "completed" | "failed";
+ /**
+ * Error Message
+ * @description Failure reason, if the call failed.
+ */
+ error_message?: string | null;
+ /**
+ * Child Session Ids
+ * @description All child graph execution state ids.
+ */
+ child_session_ids?: string[];
+ /**
+ * Expected Child Count
+ * @description The number of child executions for this call.
+ * @default 1
+ */
+ expected_child_count?: number;
+ /**
+ * Completed Child Item Ids
+ * @description The child queue item ids whose workflow_return outputs have been aggregated.
+ */
+ completed_child_item_ids?: number[];
+ /**
+ * Aggregated Collection
+ * @description The aggregated workflow_return collection accumulated from child executions.
+ */
+ aggregated_collection?: unknown[];
+ };
+ /**
+ * WorkflowCallFrame
+ * @description Represents one workflow-call frame in a nested call chain.
+ */
+ WorkflowCallFrame: {
+ /**
+ * Prepared Call Node Id
+ * @description The prepared exec node id for the call site.
+ */
+ prepared_call_node_id: string;
+ /**
+ * Source Call Node Id
+ * @description The source graph node id for the call site.
+ */
+ source_call_node_id: string;
+ /**
+ * Workflow Id
+ * @description The saved workflow being called.
+ */
+ workflow_id: string;
+ /**
+ * Depth
+ * @description The 1-based depth of this call frame.
+ */
+ depth: number;
+ };
+ /**
+ * WorkflowCallParentRef
+ * @description Reference from a child execution state back to its parent workflow-call relationship.
+ */
+ WorkflowCallParentRef: {
+ /**
+ * Workflow Call Id
+ * @description The workflow-call execution id.
+ */
+ workflow_call_id: string;
+ /**
+ * Parent Session Id
+ * @description The parent graph execution state id.
+ */
+ parent_session_id: string;
+ /**
+ * Prepared Call Node Id
+ * @description The prepared exec node id for the parent call site.
+ */
+ prepared_call_node_id: string;
+ /**
+ * Source Call Node Id
+ * @description The source graph node id for the parent call site.
+ */
+ source_call_node_id: string;
+ /**
+ * Workflow Id
+ * @description The saved workflow being called.
+ */
+ workflow_id: string;
+ /**
+ * Depth
+ * @description The 1-based depth of this call frame.
+ */
+ depth: number;
+ };
/**
* WorkflowCategory
* @enum {string}
*/
WorkflowCategory: "user" | "default";
+ /**
+ * WorkflowCreatedEvent
+ * @description Event model for workflow_created
+ */
+ WorkflowCreatedEvent: {
+ /**
+ * Timestamp
+ * @description The timestamp of the event
+ */
+ timestamp: number;
+ /**
+ * Workflow Id
+ * @description The ID of the workflow
+ */
+ workflow_id: string;
+ /**
+ * User Id
+ * @description The owner of the workflow
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether the workflow is shared with all users
+ */
+ is_public: boolean;
+ };
+ /**
+ * WorkflowDeletedEvent
+ * @description Event model for workflow_deleted
+ */
+ WorkflowDeletedEvent: {
+ /**
+ * Timestamp
+ * @description The timestamp of the event
+ */
+ timestamp: number;
+ /**
+ * Workflow Id
+ * @description The ID of the workflow
+ */
+ workflow_id: string;
+ /**
+ * User Id
+ * @description The owner of the workflow
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether the workflow was shared when it was deleted
+ */
+ is_public: boolean;
+ };
/** WorkflowMeta */
WorkflowMeta: {
/**
@@ -30891,6 +31551,8 @@ export type components = {
* @description The URL of the workflow thumbnail.
*/
thumbnail_url?: string | null;
+ /** @description Whether this workflow is currently callable by call_saved_workflow. */
+ call_saved_workflow_compatibility?: components["schemas"]["WorkflowCallCompatibility"] | null;
};
/**
* WorkflowRecordOrderBy
@@ -30942,6 +31604,91 @@ export type components = {
* @description The URL of the workflow thumbnail.
*/
thumbnail_url?: string | null;
+ /** @description Whether this workflow is currently callable by call_saved_workflow. */
+ call_saved_workflow_compatibility?: components["schemas"]["WorkflowCallCompatibility"] | null;
+ };
+ /**
+ * Workflow Return
+ * @description Defines the explicit collection result returned by a callable workflow.
+ */
+ WorkflowReturnInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default false
+ */
+ use_cache?: boolean;
+ /**
+ * Collection
+ * @description The collection returned to a calling workflow.
+ * @default []
+ */
+ collection?: unknown[];
+ /**
+ * type
+ * @default workflow_return
+ * @constant
+ */
+ type: "workflow_return";
+ };
+ /**
+ * WorkflowReturnOutput
+ * @description The explicit collection returned from a callable workflow.
+ */
+ WorkflowReturnOutput: {
+ /**
+ * Collection
+ * @description The workflow return collection
+ */
+ collection: unknown[];
+ /**
+ * type
+ * @default workflow_return_output
+ * @constant
+ */
+ type: "workflow_return_output";
+ };
+ /**
+ * WorkflowUpdatedEvent
+ * @description Event model for workflow_updated
+ */
+ WorkflowUpdatedEvent: {
+ /**
+ * Timestamp
+ * @description The timestamp of the event
+ */
+ timestamp: number;
+ /**
+ * Workflow Id
+ * @description The ID of the workflow
+ */
+ workflow_id: string;
+ /**
+ * User Id
+ * @description The owner of the workflow
+ */
+ user_id: string;
+ /**
+ * Old Is Public
+ * @description Whether the workflow was shared before the update
+ */
+ old_is_public: boolean;
+ /**
+ * New Is Public
+ * @description Whether the workflow is shared after the update
+ */
+ new_is_public: boolean;
};
/** WorkflowWithoutID */
WorkflowWithoutID: {
@@ -31177,11 +31924,6 @@ export type components = {
* @default null
*/
latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description Noise tensor
- * @default null
- */
- noise?: components["schemas"]["LatentsField"] | null;
/**
* @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
* @default null
@@ -31315,11 +32057,6 @@ export type components = {
* @default null
*/
latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description Noise tensor
- * @default null
- */
- noise?: components["schemas"]["LatentsField"] | null;
/**
* @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
* @default null
From afbe6348ba884f07acb22a168db9db0d2f29069f Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 10:32:22 -0500
Subject: [PATCH 072/100] Harden workflow call child propagation
---
.../workflow_call_runtime.py | 4 +
.../session_queue/session_queue_common.py | 3 +-
.../frontend/web/src/services/api/schema.ts | 4 +-
.../app/services/test_workflow_call_batch.py | 34 +++++++
.../services/test_workflow_call_runtime.py | 8 ++
.../app/services/workflow_call_test_utils.py | 99 +++++++++++++++++++
6 files changed, 149 insertions(+), 3 deletions(-)
diff --git a/invokeai/app/services/session_processor/workflow_call_runtime.py b/invokeai/app/services/session_processor/workflow_call_runtime.py
index 4f7e104e25b..ec2bc33e231 100644
--- a/invokeai/app/services/session_processor/workflow_call_runtime.py
+++ b/invokeai/app/services/session_processor/workflow_call_runtime.py
@@ -174,6 +174,8 @@ def _get_parent_queue_item(self, child_queue_item: SessionQueueItem) -> SessionQ
def _resume_parent_from_completed_child(self, child_queue_item: SessionQueueItem) -> None:
parent_queue_item = self._get_parent_queue_item(child_queue_item)
+ if parent_queue_item.status in ("completed", "failed", "canceled"):
+ return
try:
output = self.get_child_workflow_return_output(child_queue_item.session)
should_resume_parent, aggregated_collection = (
@@ -212,6 +214,8 @@ def _resume_parent_from_completed_child(self, child_queue_item: SessionQueueItem
def _fail_parent_from_failed_child(self, child_queue_item: SessionQueueItem) -> None:
parent_queue_item = self._get_parent_queue_item(child_queue_item)
+ if parent_queue_item.status in ("completed", "failed", "canceled"):
+ return
waiting_frame = parent_queue_item.session.waiting_workflow_call
if waiting_frame is None:
raise ValueError("Parent queue item is missing workflow call waiting state.")
diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py
index de5dbe5d5d6..37f60653046 100644
--- a/invokeai/app/services/session_queue/session_queue_common.py
+++ b/invokeai/app/services/session_queue/session_queue_common.py
@@ -51,7 +51,8 @@ class SessionQueueItemNotFoundError(ValueError):
# region Batch
-BatchDataType = Union[StrictStr, float, int, ImageField]
+BatchScalarDataType = Union[StrictStr, float, int, ImageField]
+BatchDataType = Union[BatchScalarDataType, list[BatchScalarDataType]]
class NodeFieldValue(BaseModel):
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 0a13be97b76..22219ccc8d3 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -3657,7 +3657,7 @@ export type components = {
* Items
* @description The list of items to substitute into the node/field.
*/
- items?: (string | number | components["schemas"]["ImageField"])[];
+ items?: (string | number | components["schemas"]["ImageField"] | (string | number | components["schemas"]["ImageField"])[])[];
};
/**
* BatchEnqueuedEvent
@@ -23809,7 +23809,7 @@ export type components = {
* Value
* @description The value to substitute into the node/field.
*/
- value: string | number | components["schemas"]["ImageField"];
+ value: string | number | components["schemas"]["ImageField"] | (string | number | components["schemas"]["ImageField"])[];
};
/**
* NodePackInfo
diff --git a/tests/app/services/test_workflow_call_batch.py b/tests/app/services/test_workflow_call_batch.py
index 47e2a14c72c..503f0a78a3b 100644
--- a/tests/app/services/test_workflow_call_batch.py
+++ b/tests/app/services/test_workflow_call_batch.py
@@ -112,6 +112,40 @@ def test_build_child_workflow_sessions_expands_direct_integer_batch() -> None:
assert [child_session.graph.nodes["target"].value for child_session in child_sessions] == [2, 4, 6]
+def test_build_child_workflow_sessions_expands_direct_integer_batch_into_collection_input() -> None:
+ workflow = _workflow_dump(
+ nodes=[
+ _invocation_node(
+ "batch",
+ "integer_batch",
+ {"integers": {"value": [2, 4, 6]}, "batch_group_id": {"value": "None"}},
+ ),
+ _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ ],
+ edges=[
+ {
+ "id": "edge-batch-return",
+ "type": "default",
+ "source": "batch",
+ "sourceHandle": "integers",
+ "target": "return",
+ "targetHandle": "collection",
+ },
+ ],
+ )
+
+ child_sessions = build_child_workflow_sessions(
+ parent_session=GraphExecutionState(graph=Graph()),
+ workflow=workflow,
+ workflow_inputs={},
+ call_frame=_call_frame(),
+ maximum_children=10,
+ )
+
+ assert len(child_sessions) == 3
+ assert [child_session.graph.nodes["return"].collection for child_session in child_sessions] == [[2], [4], [6]]
+
+
def test_build_child_workflow_sessions_rejects_inaccessible_image_generator_board() -> None:
workflow = _workflow_dump(
nodes=[
diff --git a/tests/app/services/test_workflow_call_runtime.py b/tests/app/services/test_workflow_call_runtime.py
index a0b2601c21a..b53d283b80e 100644
--- a/tests/app/services/test_workflow_call_runtime.py
+++ b/tests/app/services/test_workflow_call_runtime.py
@@ -27,6 +27,14 @@ def test_run_preserves_canceled_child_workflow_chain_without_failing_parent(monk
workflow_call_tests.test_run_preserves_canceled_child_workflow_chain_without_failing_parent(monkeypatch)
+def test_run_does_not_resume_canceled_parent_after_completed_child(monkeypatch) -> None:
+ workflow_call_tests.test_run_does_not_resume_canceled_parent_after_completed_child(monkeypatch)
+
+
+def test_run_does_not_fail_canceled_parent_after_child_return_error(monkeypatch) -> None:
+ workflow_call_tests.test_run_does_not_fail_canceled_parent_after_child_return_error(monkeypatch)
+
+
def test_workflow_call_coordinator_builds_child_queue_item_with_relationship_metadata(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_coordinator_builds_child_queue_item_with_relationship_metadata(monkeypatch)
diff --git a/tests/app/services/workflow_call_test_utils.py b/tests/app/services/workflow_call_test_utils.py
index 84af353e526..a23b34bea5b 100644
--- a/tests/app/services/workflow_call_test_utils.py
+++ b/tests/app/services/workflow_call_test_utils.py
@@ -2322,6 +2322,105 @@ def test_run_preserves_canceled_child_workflow_chain_without_failing_parent(
assert events.errors == []
+def test_run_does_not_resume_canceled_parent_after_completed_child(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ lifecycle = WorkflowCallQueueLifecycle(runner)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+
+ session = GraphExecutionState(graph=graph)
+ invocation = session.next()
+ assert isinstance(invocation, CallSavedWorkflowInvocation)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
+ "priority": 0,
+ "origin": None,
+ "destination": None,
+ "root_item_id": None,
+ },
+ )()
+
+ workflow_record = runner._services.workflow_records.get("workflow-a")
+ child_queue_item = runner.workflow_call_coordinator.begin_workflow_call_boundary(
+ invocation, queue_item, workflow_record
+ )
+ original_run = runner.run
+
+ def run_child_then_cancel_parent(queue_item):
+ original_run(queue_item)
+ session_queue.items[1].status = "canceled"
+
+ monkeypatch.setattr(runner, "run", run_child_then_cancel_parent)
+
+ lifecycle.run_queue_item(child_queue_item)
+
+ assert session_queue.items[1].status == "canceled"
+ assert session_queue.completed_item_ids == [child_queue_item.item_id]
+ assert session_queue.resumed_item_ids == []
+ assert [event for event in events.completed if event[0].get_type() == "call_saved_workflow"] == []
+
+
+def test_run_does_not_fail_canceled_parent_after_child_return_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ lifecycle = WorkflowCallQueueLifecycle(runner)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+
+ session = GraphExecutionState(graph=graph)
+ invocation = session.next()
+ assert isinstance(invocation, CallSavedWorkflowInvocation)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
+ "priority": 0,
+ "origin": None,
+ "destination": None,
+ "root_item_id": None,
+ },
+ )()
+
+ workflow_record = runner._services.workflow_records.get("workflow-a")
+ child_queue_item = runner.workflow_call_coordinator.begin_workflow_call_boundary(
+ invocation, queue_item, workflow_record
+ )
+
+ def fail_child_after_parent_canceled(queue_item):
+ queue_item.status = "failed"
+ queue_item.error_message = "child failed after parent cancel"
+ session_queue.items[queue_item.item_id] = queue_item
+ session_queue.items[1].status = "canceled"
+
+ monkeypatch.setattr(runner, "run", fail_child_after_parent_canceled)
+
+ lifecycle.run_queue_item(child_queue_item)
+
+ assert session_queue.items[1].status == "canceled"
+ assert session_queue.failed_item_ids == []
+ assert session.errors == {}
+ assert [event for event in events.errors if event[1].get_type() == "call_saved_workflow"] == []
+
+
def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
From 77e01848b4245714648bcec6d8676e4b2ca74b15 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 15:18:20 -0500
Subject: [PATCH 073/100] Tighten workflow call compatibility reporting
---
docs/contributing/call_saved_workflow.md | 4 +
invokeai/app/services/shared/README.md | 4 +
.../shared/workflow_call_compatibility.py | 18 +++-
.../test_workflow_call_compatibility.py | 102 ++++++++++++++++++
4 files changed, 127 insertions(+), 1 deletion(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 6010afd056c..2736935993f 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -132,6 +132,8 @@ Implemented conversion helper:
- the selected workflow must contain exactly one `workflow_return` node
- connected batch child inputs produced by ordinary non-generator upstream nodes still fail early with a clear
unsupported-feature error
+ - malformed batch input wiring, including multiple connected inputs to one batch field, is reported as
+ `unsupported_batch_input` compatibility rather than a generic unsupported-node failure
- child workflows that mix supported batch nodes with unrelated generator nodes are currently rejected with a clear
unsupported-feature error
- unsupported callees are rejected before any child queue row is created
@@ -313,6 +315,8 @@ Current semantics:
- grouped batch nodes zip by `batch_group_id`
- the workflow call creates one child queue row per expanded batch session
- supported generator value shapes are resolved into concrete batch items before queue batch expansion
+- batch outputs may feed `workflow_return.collection` directly; each expanded child receives a singleton collection, and
+ the parent still aggregates all returned child collections
- parent resume waits for all child rows tied to that workflow call
- parent return aggregation appends each child `workflow_return.collection` into one parent collection
- if any child row fails, remaining sibling child rows are canceled and the parent call fails
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 5e0f287ffe9..d7c67d19919 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -294,12 +294,16 @@ Current limitation:
by a dedicated workflow-call queue lifecycle component rather than a generalized queue scheduler contract.
- Called workflows currently require exactly one valid `workflow_return` node to be callable at all.
- Direct batch-special child workflows are now supported by expanding them into multiple child queue rows.
+- Batch outputs may feed `workflow_return.collection` directly; each expanded child receives a singleton collection and
+ parent resume aggregates those child return collections.
- Generator-backed batch child workflows are now supported when the batch node is fed directly by a supported integer,
float, string, or image generator.
- Connected batch child inputs produced by ordinary non-generator upstream nodes are still rejected before any child
queue row is created.
- Workflow library API responses now include compatibility metadata so the frontend can disable unsupported callees
before execution rather than failing only at runtime.
+- Batch-specific compatibility failures, including multiple connected inputs to one batch field, are reported as
+ `unsupported_batch_input` rather than generic unsupported-node failures.
- The workflow library list also surfaces that metadata as an informational unsupported state; workflows remain
viewable/editable even when they are not currently callable by `call_saved_workflow`.
diff --git a/invokeai/app/services/shared/workflow_call_compatibility.py b/invokeai/app/services/shared/workflow_call_compatibility.py
index a97d438cb8b..df3e46f1a84 100644
--- a/invokeai/app/services/shared/workflow_call_compatibility.py
+++ b/invokeai/app/services/shared/workflow_call_compatibility.py
@@ -127,6 +127,22 @@ def _build_compatibility_workflow_inputs(workflow: dict[str, Any]) -> dict[str,
return workflow_inputs
+def _is_unsupported_batch_input_message(message: str) -> bool:
+ return any(
+ marker in message
+ for marker in (
+ "batch child workflow",
+ "batch group",
+ "batch input",
+ "batch inputs",
+ "batch node",
+ "batch-special child workflow nodes",
+ "connected batch",
+ "generator-backed batch",
+ )
+ )
+
+
def get_workflow_call_compatibility(
*,
workflow: dict[str, Any],
@@ -174,7 +190,7 @@ def get_workflow_call_compatibility(
except UnsupportedWorkflowNodeError as e:
message = str(e)
reason = WorkflowCallCompatibilityReason.UnsupportedNode
- if "connected batch child workflow inputs" in message:
+ if _is_unsupported_batch_input_message(message):
reason = WorkflowCallCompatibilityReason.UnsupportedBatchInput
elif "exactly one workflow_return" in message:
reason = WorkflowCallCompatibilityReason.MissingWorkflowReturn
diff --git a/tests/app/services/test_workflow_call_compatibility.py b/tests/app/services/test_workflow_call_compatibility.py
index 5299fe19f8a..c17077a3855 100644
--- a/tests/app/services/test_workflow_call_compatibility.py
+++ b/tests/app/services/test_workflow_call_compatibility.py
@@ -191,6 +191,108 @@ def test_get_workflow_call_compatibility_reports_unsupported_connected_batch_inp
assert "connected batch child workflow inputs" in (compatibility.message or "")
+def test_get_workflow_call_compatibility_allows_batch_directly_into_workflow_return_collection() -> None:
+ workflow = _workflow_dump(
+ nodes=[
+ _invocation_node(
+ "batch", "integer_batch", {"integers": {"value": [2, 4]}, "batch_group_id": {"value": "None"}}
+ ),
+ _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ ],
+ edges=[
+ {
+ "id": "edge-batch-return",
+ "type": "default",
+ "source": "batch",
+ "sourceHandle": "integers",
+ "target": "return",
+ "targetHandle": "collection",
+ },
+ ],
+ )
+
+ compatibility = get_workflow_call_compatibility(
+ workflow=workflow,
+ workflow_id="workflow-a",
+ services=_services(),
+ user_id="user-1",
+ maximum_children=1000,
+ )
+
+ assert compatibility.is_callable is True
+ assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
+ assert compatibility.message is None
+
+
+def test_get_workflow_call_compatibility_reports_multiple_batch_inputs_as_unsupported_batch_input() -> None:
+ workflow = _workflow_dump(
+ nodes=[
+ _invocation_node("source-a", "integer", {"value": {"value": 7}}),
+ _invocation_node("source-b", "integer", {"value": {"value": 8}}),
+ _invocation_node(
+ "batch", "integer_batch", {"integers": {"value": []}, "batch_group_id": {"value": "None"}}
+ ),
+ _invocation_node("target", "integer", {"value": {"value": 0}}),
+ _invocation_node("collect", "collect", {"collection": {"value": []}}),
+ _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ ],
+ edges=[
+ {
+ "id": "edge-source-a-batch",
+ "type": "default",
+ "source": "source-a",
+ "sourceHandle": "value",
+ "target": "batch",
+ "targetHandle": "integers",
+ },
+ {
+ "id": "edge-source-b-batch",
+ "type": "default",
+ "source": "source-b",
+ "sourceHandle": "value",
+ "target": "batch",
+ "targetHandle": "integers",
+ },
+ {
+ "id": "edge-batch-target",
+ "type": "default",
+ "source": "batch",
+ "sourceHandle": "integers",
+ "target": "target",
+ "targetHandle": "value",
+ },
+ {
+ "id": "edge-target-collect",
+ "type": "default",
+ "source": "target",
+ "sourceHandle": "value",
+ "target": "collect",
+ "targetHandle": "item",
+ },
+ {
+ "id": "edge-collect-return",
+ "type": "default",
+ "source": "collect",
+ "sourceHandle": "collection",
+ "target": "return",
+ "targetHandle": "collection",
+ },
+ ],
+ )
+
+ compatibility = get_workflow_call_compatibility(
+ workflow=workflow,
+ workflow_id="workflow-a",
+ services=_services(),
+ user_id="user-1",
+ maximum_children=1000,
+ )
+
+ assert compatibility.is_callable is False
+ assert compatibility.reason is WorkflowCallCompatibilityReason.UnsupportedBatchInput
+ assert "multiple connected batch inputs" in (compatibility.message or "")
+
+
def test_get_workflow_call_compatibility_allows_workflow_with_required_exposed_input() -> None:
workflow = _workflow_dump(
nodes=[
From 40833d5fc4f76bf2fde747dc54e57604ba4174a0 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 15:28:17 -0500
Subject: [PATCH 074/100] Removed to-do items that have been done
---
docs/contributing/call_saved_workflow.md | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 2736935993f..44964033fcd 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -588,14 +588,15 @@ Already covered:
- child `workflow_return` output is captured and becomes the parent `call_saved_workflow` output
- child workflows without a `workflow_return` node fail cleanly when called
- child execution events now include stable workflow-call relationship metadata on the child `SessionQueueItem`
+- parent-child resume and failure propagation through queue-visible child rows
+- nested runtime execution with bounded stack depth
+- direct and generator-backed batch-special child workflows through queue child-row expansion
+- compatibility metadata for required exposed inputs, missing/multiple returns, supported batch-to-return collection
+ shapes, and unsupported batch input wiring
Still needed in later increments:
-- parent-child resume behavior once child execution is no longer an inline runner detail
-- child failure propagation into parent failure
-- nested runtime execution beyond a single attached child state
-- eventual support for child workflows that contain batch-special nodes, once child execution is run through the proper
- batch/session path
+- focused coverage for any newly supported batch or generator shape when its contract changes
- eventual migration from dedicated workflow-call queue lifecycle handling to a more general scheduler or
queue-lifecycle model
From f64205fa4b8b93b9de7fc08cd53558793561b73d Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 15:32:43 -0500
Subject: [PATCH 075/100] Update docs
---
docs/contributing/call_saved_workflow.md | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 44964033fcd..dc92da76763 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -495,8 +495,8 @@ Next runtime work still needed:
- decide whether parent resumption should remain immediate on child completion or become a more explicit scheduler step
- decide whether `WorkflowCallQueueLifecycle` should remain a dedicated workflow-call runtime component or eventually
fold into a more general queue scheduler/lifecycle layer
-- replace the current unsupported batch-special-node limitation by routing child execution through machinery that can
- honor ordinary Invoke batch semantics
+- if support expands beyond the currently supported direct and generator-backed batch shapes, route those new child
+ workflow execution shapes through machinery that can honor ordinary Invoke batch semantics
## Suggested Runtime Components
@@ -604,9 +604,10 @@ Still needed in later increments:
The next incremental step should be:
-- move the temporary attached-session processor path toward the intended first-class parent-child runtime model
-- keep that step test-first
-- preserve the current bounded nested-call runtime state while making return/resume behavior explicit
+- stop adding feature slices unless they close a concrete correctness gap or unlock a realistic user workflow
+- stabilize the current branch with review, targeted test runs, and cleanup of stale design-doc language
+- treat migration from `WorkflowCallQueueLifecycle` to a generalized parent/child queue lifecycle as a larger
+ architecture slice, not as small follow-on busywork
The current branch is at the point where:
From 1fbd119e2acaabc5ffb684685c4f9bc38daf0e54 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 18:41:52 -0500
Subject: [PATCH 076/100] Documented `WorkflowCallQueueLifecycle` as an
intentional bounded component
---
docs/contributing/call_saved_workflow.md | 12 +++++++-----
invokeai/app/services/shared/README.md | 5 +++--
2 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index dc92da76763..3ac6b67c209 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -492,9 +492,11 @@ Current insertion points already used:
Next runtime work still needed:
-- decide whether parent resumption should remain immediate on child completion or become a more explicit scheduler step
-- decide whether `WorkflowCallQueueLifecycle` should remain a dedicated workflow-call runtime component or eventually
- fold into a more general queue scheduler/lifecycle layer
+- keep `WorkflowCallQueueLifecycle` as the bounded workflow-call lifecycle component for this PR
+ - the current workflow-call feature is the only caller of parent/child queue semantics
+ - replacing it with a generalized queue dependency scheduler now would add regression risk without unlocking a
+ concrete user workflow
+ - revisit only if another feature needs dependent queue items, richer retry/cancel policies, or resumable waits
- if support expands beyond the currently supported direct and generator-backed batch shapes, route those new child
workflow execution shapes through machinery that can honor ordinary Invoke batch semantics
@@ -597,8 +599,8 @@ Already covered:
Still needed in later increments:
- focused coverage for any newly supported batch or generator shape when its contract changes
-- eventual migration from dedicated workflow-call queue lifecycle handling to a more general scheduler or
- queue-lifecycle model
+- possible migration from dedicated workflow-call queue lifecycle handling to a more general scheduler or
+ queue-lifecycle model only if another feature needs reusable dependent queue items
## Recommended Immediate Next Step
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index d7c67d19919..a7f1f2846c4 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -290,8 +290,9 @@ In normal execution, all runtime expansion occurs in `execution_graph` with trac
Current limitation:
-- Child workflow executions are now represented as first-class queue items, but parent resume/failure is still handled
- by a dedicated workflow-call queue lifecycle component rather than a generalized queue scheduler contract.
+- Child workflow executions are now represented as first-class queue items. Parent resume/failure is intentionally
+ handled by a dedicated workflow-call queue lifecycle component for this PR because no other feature currently needs a
+ generalized dependent-queue scheduler.
- Called workflows currently require exactly one valid `workflow_return` node to be callable at all.
- Direct batch-special child workflows are now supported by expanding them into multiple child queue rows.
- Batch outputs may feed `workflow_return.collection` directly; each expanded child receives a singleton collection and
From 31755aca16f873a8d3df28ea780938f4f707383c Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 20:04:41 -0500
Subject: [PATCH 077/100] Fix saved workflow selection fallback
---
.../components/StagingArea/state.ts | 2 +-
.../SavedWorkflowFieldInputComponent.tsx | 19 +++++++-
.../inputs/savedWorkflowFieldUtils.test.ts | 47 +++++++++++++++++++
.../fields/inputs/savedWorkflowFieldUtils.ts | 26 +++++++++-
4 files changed, 89 insertions(+), 5 deletions(-)
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts
index 818b93a648e..4ee71acdff3 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts
@@ -70,8 +70,8 @@ const getQueueItemStatusRank = (status: S['SessionQueueItem']['status']): number
switch (status) {
case 'pending':
return 0;
- case 'in_progress':
// Waiting items are suspended on child workflow execution, but they are still nonterminal.
+ case 'in_progress':
case 'waiting':
return 1;
case 'completed':
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
index 3262b962503..9aafd9e808a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
@@ -7,11 +7,12 @@ import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { SavedWorkflowFieldInputInstance, SavedWorkflowFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
+import { useGetWorkflowQuery, useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
import {
buildSavedWorkflowOptions,
getSavedWorkflowDisplayState,
+ getSavedWorkflowListItemFromRecord,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
MISSING_WORKFLOW_OPTION_VALUE,
@@ -44,9 +45,23 @@ const SavedWorkflowFieldInputComponent = (
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { items, isLoading, isFetching } = useListWorkflowsInfiniteInfiniteQuery(queryArg, queryOptions);
+ const isSelectedWorkflowInList = useMemo(
+ () => items.some((workflow) => workflow.workflow_id === field.value),
+ [field.value, items]
+ );
+ const { data: selectedWorkflowRecord } = useGetWorkflowQuery(field.value, {
+ skip: !field.value || isSelectedWorkflowInList,
+ });
+ const selectedWorkflow = useMemo(
+ () => (selectedWorkflowRecord ? getSavedWorkflowListItemFromRecord(selectedWorkflowRecord) : undefined),
+ [selectedWorkflowRecord]
+ );
const options = useMemo(() => buildSavedWorkflowOptions(items), [items]);
- const selectionState = useMemo(() => getSavedWorkflowSelectionState(items, field.value), [field.value, items]);
+ const selectionState = useMemo(
+ () => getSavedWorkflowSelectionState(items, field.value, selectedWorkflow),
+ [field.value, items, selectedWorkflow]
+ );
const value = useMemo(() => {
const option = getSavedWorkflowSelectionOption(selectionState);
if (option?.value === MISSING_WORKFLOW_OPTION_VALUE) {
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
index 2f0647b2cac..9f7f0195461 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
import {
buildSavedWorkflowOptions,
getSavedWorkflowDisplayState,
+ getSavedWorkflowListItemFromRecord,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
MISSING_WORKFLOW_OPTION_VALUE,
@@ -106,4 +107,50 @@ describe('savedWorkflowFieldUtils', () => {
compatibilityMessage: null,
});
});
+
+ it('uses a fetched selected workflow when it is not present in the first list page', () => {
+ const selectedWorkflow = getSavedWorkflowListItemFromRecord({
+ workflow_id: 'workflow-z',
+ name: 'Zeta Workflow',
+ created_at: '',
+ updated_at: '',
+ opened_at: null,
+ user_id: 'user-z',
+ is_public: true,
+ thumbnail_url: null,
+ call_saved_workflow_compatibility: {
+ is_callable: true,
+ reason: 'ok',
+ message: null,
+ },
+ workflow: {
+ id: 'workflow-z',
+ name: 'Zeta Workflow',
+ author: '',
+ description: 'A workflow outside the first page',
+ version: '',
+ contact: '',
+ tags: 'paged',
+ notes: '',
+ exposedFields: [],
+ meta: {
+ category: 'user',
+ version: '1.0.0',
+ },
+ nodes: [],
+ edges: [],
+ form: null,
+ },
+ });
+
+ const selectionState = getSavedWorkflowSelectionState(workflows, 'workflow-z', selectedWorkflow);
+
+ expect(selectionState).toEqual({ status: 'selected', workflow: selectedWorkflow });
+ expect(getSavedWorkflowDisplayState(selectionState)).toEqual({
+ selection: 'selected',
+ statusLabelKey: null,
+ badges: ['shared'],
+ compatibilityMessage: null,
+ });
+ });
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index 0947cc05ba7..a29b63bc3a8 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -1,6 +1,6 @@
import type { ComboboxOption } from '@invoke-ai/ui-library';
import { getWorkflowCallCompatibilityState } from 'features/workflowLibrary/util/workflowCallCompatibility';
-import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
+import type { S, WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
export type SavedWorkflowBadge = 'unsupported' | 'default' | 'shared';
@@ -20,7 +20,8 @@ export const buildSavedWorkflowOptions = (workflows: WorkflowRecordListItemWithT
export const getSavedWorkflowSelectionState = (
workflows: WorkflowRecordListItemWithThumbnailDTO[],
- workflowId: string
+ workflowId: string,
+ selectedWorkflow?: WorkflowRecordListItemWithThumbnailDTO
): SavedWorkflowSelectionState => {
if (!workflowId) {
return { status: 'unselected' };
@@ -31,9 +32,30 @@ export const getSavedWorkflowSelectionState = (
return { status: 'selected', workflow };
}
+ if (selectedWorkflow?.workflow_id === workflowId) {
+ return { status: 'selected', workflow: selectedWorkflow };
+ }
+
return { status: 'missing', workflowId };
};
+export const getSavedWorkflowListItemFromRecord = (
+ workflow: S['WorkflowRecordWithThumbnailDTO']
+): WorkflowRecordListItemWithThumbnailDTO => ({
+ workflow_id: workflow.workflow_id,
+ name: workflow.name,
+ created_at: workflow.created_at,
+ updated_at: workflow.updated_at,
+ opened_at: workflow.opened_at,
+ user_id: workflow.user_id,
+ is_public: workflow.is_public,
+ description: workflow.workflow.description,
+ category: workflow.workflow.meta.category,
+ tags: workflow.workflow.tags,
+ thumbnail_url: workflow.thumbnail_url,
+ call_saved_workflow_compatibility: workflow.call_saved_workflow_compatibility,
+});
+
export const getSavedWorkflowSelectionOption = (selectionState: SavedWorkflowSelectionState): ComboboxOption | null => {
if (selectionState.status === 'unselected') {
return null;
From 78555fdbb9c78556e1bcfb56910f74da84a2694b Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 20:46:50 -0500
Subject: [PATCH 078/100] Fix workflow call startup cancellation
---
docs/contributing/call_saved_workflow.md | 2 +
.../session_queue/session_queue_sqlite.py | 26 ++++-
invokeai/app/services/shared/README.md | 1 +
...st_session_queue_workflow_call_metadata.py | 103 ++++++++++++++++++
4 files changed, 130 insertions(+), 2 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 3ac6b67c209..20e99b5c132 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -406,6 +406,8 @@ Cancel behavior:
- canceling a waiting parent cancels descendant child rows
- canceling a child row cancels waiting ancestors
- cancelation should stay cancelation; it should not be rewritten into ordinary failure semantics
+- startup recovery cancels any interrupted `in_progress` or `waiting` workflow-call chain, including pending descendants,
+ so a restart cannot leave a suspended parent waiting on a child row that will never report back
Retry behavior:
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index 5517aedfae0..ee33654def5 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -72,11 +72,33 @@ def _set_in_progress_to_canceled(self) -> None:
with self._db.transaction() as cursor:
cursor.execute(
"""--sql
+ SELECT item_id
+ FROM session_queue
+ WHERE status = 'in_progress'
+ OR status = 'waiting';
+ """
+ )
+ interrupted_item_ids = [row[0] for row in cast(list[sqlite3.Row], cursor.fetchall())]
+ item_ids_to_cancel: set[int] = set()
+ for item_id in interrupted_item_ids:
+ item_ids_to_cancel.update(self._get_workflow_call_chain_item_ids(item_id))
+ if not item_ids_to_cancel:
+ return
+ with self._db.transaction() as cursor:
+ placeholders = ",".join("?" for _ in item_ids_to_cancel)
+ cursor.execute(
+ f"""--sql
UPDATE session_queue
SET status = 'canceled',
status_sequence = COALESCE(status_sequence, 0) + 1
- WHERE status = 'in_progress';
- """
+ WHERE item_id IN ({placeholders})
+ AND (
+ status = 'pending'
+ OR status = 'in_progress'
+ OR status = 'waiting'
+ );
+ """,
+ tuple(item_ids_to_cancel),
)
def _prune_terminal_to_limit(self, queue_id: str, keep: int) -> int:
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index a7f1f2846c4..bf5d97bdf8b 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -156,6 +156,7 @@ Workflow-call note:
- child failure fails the waiting parent and can cascade upward through ancestors
- failing child rows cancel their remaining workflow-call siblings before the parent is failed
- cancelation is chain-aware across parents and children
+ - startup recovery cancels interrupted `in_progress` or `waiting` workflow-call chains, including pending descendants
- deleting a workflow-call queue row currently deletes the whole parent/child chain rather than leaving orphaned rows
behind
- retry is root-oriented and should not be exposed directly on child queue rows in the UI
diff --git a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
index 26a63644ee0..9fd5a389a86 100644
--- a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
+++ b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
@@ -261,6 +261,109 @@ def test_get_queue_status_counts_waiting_items(session_queue: SqliteSessionQueue
assert queue_status.total == 1
+def test_startup_cancellation_cancels_waiting_workflow_call_chain(session_queue: SqliteSessionQueue) -> None:
+ parent_session = GraphExecutionState(graph=Graph())
+ child_session = GraphExecutionState(graph=Graph())
+ sibling_session = GraphExecutionState(graph=Graph())
+ batch_id = str(uuid.uuid4())
+
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id,
+ session,
+ session_id,
+ batch_id,
+ field_values,
+ priority,
+ workflow,
+ origin,
+ destination,
+ retried_from_item_id,
+ user_id,
+ status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ parent_session.model_dump_json(warnings=False),
+ parent_session.id,
+ batch_id,
+ None,
+ 0,
+ None,
+ None,
+ None,
+ None,
+ "user-1",
+ "waiting",
+ ),
+ )
+ parent_item_id = cursor.lastrowid
+ for child_status, session in (("in_progress", child_session), ("pending", sibling_session)):
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id,
+ session,
+ session_id,
+ batch_id,
+ field_values,
+ priority,
+ workflow,
+ origin,
+ destination,
+ retried_from_item_id,
+ user_id,
+ workflow_call_id,
+ parent_item_id,
+ parent_session_id,
+ root_item_id,
+ workflow_call_depth,
+ status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ session.model_dump_json(warnings=False),
+ session.id,
+ batch_id,
+ None,
+ 0,
+ None,
+ None,
+ None,
+ None,
+ "user-1",
+ "workflow-call-1",
+ parent_item_id,
+ parent_session.id,
+ parent_item_id,
+ 1,
+ child_status,
+ ),
+ )
+
+ session_queue._set_in_progress_to_canceled()
+
+ assert session_queue.get_queue_item(parent_item_id).status == "canceled"
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT status
+ FROM session_queue
+ WHERE parent_item_id = ?
+ ORDER BY item_id ASC
+ """,
+ (parent_item_id,),
+ )
+ child_statuses = [row[0] for row in cursor.fetchall()]
+ assert child_statuses == ["canceled", "canceled"]
+
+
def test_cancel_queue_item_cascades_from_waiting_parent_to_child_chain(session_queue: SqliteSessionQueue) -> None:
parent_session = GraphExecutionState(graph=Graph())
child_session = GraphExecutionState(graph=Graph())
From 934c1eb7548b3731686ec8d56d492897b5af8cba Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 20:49:56 -0500
Subject: [PATCH 079/100] Fix single-user workflow socket rooms
---
docs/contributing/call_saved_workflow.md | 3 +++
invokeai/app/api/sockets.py | 3 +++
tests/app/test_workflow_socketio.py | 19 +++++++++++++++++++
3 files changed, 25 insertions(+)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 20e99b5c132..5116c40c8bf 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -286,6 +286,9 @@ The current queue-visible implementation uses the following lifecycle contract:
- child queue rows should not expose direct retry affordances in the UI
- retry websocket delivery is owner-scoped; when an admin retries roots owned by multiple users, each non-admin user
must receive only the retry item ids for their own roots, while admins can still observe the full retried set
+- workflow live-update sockets join workflow event rooms in both authenticated multiuser mode and unauthenticated
+ single-user mode; the frontend relies on those events to invalidate workflow library data and clear deleted saved
+ workflow selections
This is now part of the intended user-facing contract, even though the orchestration still lives in
`WorkflowCallCoordinator`.
diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py
index 089988782cc..d16b062110c 100644
--- a/invokeai/app/api/sockets.py
+++ b/invokeai/app/api/sockets.py
@@ -195,6 +195,9 @@ async def _handle_connect(self, sid: str, environ: dict, auth: dict | None) -> b
"is_admin": True,
}
logger.debug(f"Socket {sid} connected as system admin (single-user mode)")
+ await self._sio.enter_room(sid, "user:system")
+ await self._sio.enter_room(sid, "workflows:shared")
+ await self._sio.enter_room(sid, "admin")
return True
@staticmethod
diff --git a/tests/app/test_workflow_socketio.py b/tests/app/test_workflow_socketio.py
index c95ab3ac233..ba9be84fba8 100644
--- a/tests/app/test_workflow_socketio.py
+++ b/tests/app/test_workflow_socketio.py
@@ -22,6 +22,11 @@ def _patch_multiuser_context(monkeypatch: pytest.MonkeyPatch, *, user_id: str, i
)
+def _patch_single_user_context(monkeypatch: pytest.MonkeyPatch) -> None:
+ invoker = SimpleNamespace(services=SimpleNamespace(configuration=SimpleNamespace(multiuser=False)))
+ monkeypatch.setattr("invokeai.app.api.dependencies.ApiDependencies", SimpleNamespace(invoker=invoker))
+
+
@pytest.mark.anyio
async def test_authenticated_user_joins_workflow_rooms_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
socketio = SocketIO(FastAPI())
@@ -49,6 +54,20 @@ async def test_admin_joins_admin_room_on_connect(monkeypatch: pytest.MonkeyPatch
socketio._sio.enter_room.assert_any_call("sid-1", "admin")
+@pytest.mark.anyio
+async def test_single_user_socket_joins_workflow_rooms_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
+ socketio = SocketIO(FastAPI())
+ socketio._sio.enter_room = AsyncMock()
+ _patch_single_user_context(monkeypatch)
+
+ accepted = await socketio._handle_connect("sid-1", {}, None)
+
+ assert accepted is True
+ socketio._sio.enter_room.assert_any_call("sid-1", "user:system")
+ socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
+ socketio._sio.enter_room.assert_any_call("sid-1", "admin")
+
+
@pytest.mark.anyio
async def test_private_workflow_event_is_emitted_only_to_owner_and_admin() -> None:
socketio = SocketIO(FastAPI())
From 8c230d110b76063898b3214b2ea4853976e362ce Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 20:54:17 -0500
Subject: [PATCH 080/100] Ignore stale saved workflow input payloads
---
docs/contributing/call_saved_workflow.md | 3 +-
.../nodes/util/graph/buildNodesGraph.test.ts | 39 +++++++++++++++++++
.../nodes/util/graph/buildNodesGraph.ts | 8 ++++
3 files changed, 49 insertions(+), 1 deletion(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 5116c40c8bf..e97b696d820 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -100,7 +100,8 @@ Implemented runtime scaffolding:
- Parent queue items now enter a real `waiting` status while suspended on a child workflow execution.
- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
- Dynamic call arguments now execute end-to-end in the current runner path:
- - literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node
+ - literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node at graph-build time
+ - stale hidden `workflow_inputs` values from recalled graphs are ignored unless a matching current dynamic field exists
- connected dynamic values are accepted as special call-boundary edges and are resolved from parent results at runtime
- both are validated against the child workflow's exposed form interface before being applied to the child graph
- Queue lifecycle semantics now exist for workflow-call chains:
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
index b6cbdcb2525..a75d06019c7 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
@@ -165,6 +165,45 @@ describe('buildNodesGraph', () => {
});
});
+ it('does not serialize stale hidden saved workflow input values without matching dynamic fields', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const node = buildNode(callSavedWorkflowTemplate);
+ node.data.inputs.workflow_inputs = {
+ name: 'workflow_inputs',
+ type: 'workflow_inputs',
+ value: {
+ ['saved_workflow_input::old-node::a']: 23,
+ },
+ } as never;
+ state.nodes.push(node);
+ const templatesWithWorkflowInputs = {
+ ...templates,
+ call_saved_workflow: {
+ ...callSavedWorkflowTemplate,
+ inputs: {
+ ...callSavedWorkflowTemplate.inputs,
+ workflow_inputs: buildDynamicIntegerTemplate('workflow_inputs'),
+ },
+ },
+ };
+
+ const rootState = {
+ nodes: {
+ past: [],
+ future: [],
+ present: state,
+ },
+ gallery: {
+ autoAddBoardId: 'none',
+ },
+ } as never;
+
+ const graph = buildNodesGraph(rootState, templatesWithWorkflowInputs);
+
+ expect(graph.nodes[node.id].workflow_id).toBe('');
+ expect(graph.nodes[node.id].workflow_inputs).toEqual({});
+ });
+
it('flattens a single connector to one direct execution edge', () => {
const source = buildNode(add);
const target = buildNode(sub);
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
index 0cd28901dad..eb2ce06d152 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
@@ -65,6 +65,10 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require
const transformedInputs = reduce(
inputs,
(inputsAccumulator, input, name) => {
+ if (type === 'call_saved_workflow' && name === 'workflow_inputs') {
+ return inputsAccumulator;
+ }
+
if (type === 'call_saved_workflow' && name.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX)) {
const workflowInputs = {
...((inputsAccumulator['workflow_inputs'] as Record | undefined) ?? {}),
@@ -90,6 +94,10 @@ export const buildNodesGraph = (state: RootState, templates: Templates): Require
{} as Record, unknown>
);
+ if (type === 'call_saved_workflow' && transformedInputs['workflow_inputs'] === undefined) {
+ transformedInputs['workflow_inputs'] = {};
+ }
+
// add reserved use_cache
transformedInputs['use_cache'] = node.data.useCache;
From 5aeb3f3e3f85c44c8cac74c324fa9afd83b1b73a Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 20:57:17 -0500
Subject: [PATCH 081/100] Fix saved workflow picker discovery
---
docs/contributing/call_saved_workflow.md | 2 +
.../SavedWorkflowFieldInputComponent.tsx | 59 +++++++++++++++----
.../inputs/savedWorkflowFieldUtils.test.ts | 40 +++++++++++++
.../fields/inputs/savedWorkflowFieldUtils.ts | 45 ++++++++++++++
4 files changed, 134 insertions(+), 12 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index e97b696d820..572ba4decb0 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -290,6 +290,8 @@ The current queue-visible implementation uses the following lifecycle contract:
- workflow live-update sockets join workflow event rooms in both authenticated multiuser mode and unauthenticated
single-user mode; the frontend relies on those events to invalidate workflow library data and clear deleted saved
workflow selections
+- the saved-workflow node picker queries owned/default workflows and public shared workflows separately, merges them by
+ workflow id, and fetches additional pages as the combobox menu reaches the end
This is now part of the intended user-facing contract, even though the orchestration still lives in
`WorkflowCallCoordinator`.
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
index 9aafd9e808a..4892821f838 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
@@ -11,25 +11,24 @@ import { useGetWorkflowQuery, useListWorkflowsInfiniteInfiniteQuery } from 'serv
import {
buildSavedWorkflowOptions,
+ getSavedWorkflowPickerOwnedQueryArg,
+ getSavedWorkflowPickerSharedQueryArg,
getSavedWorkflowDisplayState,
getSavedWorkflowListItemFromRecord,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
+ mergeSavedWorkflowPickerItems,
MISSING_WORKFLOW_OPTION_VALUE,
+ shouldFetchNextSavedWorkflowPickerPage,
} from './savedWorkflowFieldUtils';
import type { FieldComponentProps } from './types';
-const queryArg = {
- page: 0,
- per_page: 50,
- order_by: 'name',
- direction: 'ASC',
- categories: ['user', 'default'],
- query: '',
- tags: [],
- has_been_opened: undefined,
- is_public: undefined,
-} satisfies Parameters[0];
+const ownedQueryArg = getSavedWorkflowPickerOwnedQueryArg() satisfies Parameters<
+ typeof useListWorkflowsInfiniteInfiniteQuery
+>[0];
+const sharedQueryArg = getSavedWorkflowPickerSharedQueryArg() satisfies Parameters<
+ typeof useListWorkflowsInfiniteInfiniteQuery
+>[0];
const queryOptions = {
selectFromResult: ({ data, ...rest }) => ({
@@ -44,7 +43,21 @@ const SavedWorkflowFieldInputComponent = (
const { nodeId, field } = props;
const dispatch = useAppDispatch();
const { t } = useTranslation();
- const { items, isLoading, isFetching } = useListWorkflowsInfiniteInfiniteQuery(queryArg, queryOptions);
+ const {
+ items: ownedItems,
+ isLoading: isOwnedLoading,
+ isFetching: isOwnedFetching,
+ hasNextPage: hasNextOwnedPage,
+ fetchNextPage: fetchNextOwnedPage,
+ } = useListWorkflowsInfiniteInfiniteQuery(ownedQueryArg, queryOptions);
+ const {
+ items: sharedItems,
+ isLoading: isSharedLoading,
+ isFetching: isSharedFetching,
+ hasNextPage: hasNextSharedPage,
+ fetchNextPage: fetchNextSharedPage,
+ } = useListWorkflowsInfiniteInfiniteQuery(sharedQueryArg, queryOptions);
+ const items = useMemo(() => mergeSavedWorkflowPickerItems(ownedItems, sharedItems), [ownedItems, sharedItems]);
const isSelectedWorkflowInList = useMemo(
() => items.some((workflow) => workflow.workflow_id === field.value),
[field.value, items]
@@ -90,8 +103,29 @@ const SavedWorkflowFieldInputComponent = (
},
[dispatch, field.name, nodeId]
);
+ const onMenuScrollToBottom = useCallback(() => {
+ if (
+ shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: hasNextOwnedPage, isFetching: isOwnedFetching })
+ ) {
+ fetchNextOwnedPage();
+ }
+ if (
+ shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: hasNextSharedPage, isFetching: isSharedFetching })
+ ) {
+ fetchNextSharedPage();
+ }
+ }, [
+ fetchNextOwnedPage,
+ fetchNextSharedPage,
+ hasNextOwnedPage,
+ hasNextSharedPage,
+ isOwnedFetching,
+ isSharedFetching,
+ ]);
const noOptionsMessage = useCallback(() => t('nodes.noMatchingWorkflows'), [t]);
+ const isLoading = isOwnedLoading || isSharedLoading;
+ const isFetching = isOwnedFetching || isSharedFetching;
return (
@@ -100,6 +134,7 @@ const SavedWorkflowFieldInputComponent = (
value={value}
options={options}
onChange={onChange}
+ onMenuScrollToBottom={onMenuScrollToBottom}
placeholder={isLoading ? t('common.loading') : t('controlLayers.workflowIntegration.selectPlaceholder')}
noOptionsMessage={noOptionsMessage}
isClearable
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
index 9f7f0195461..a519d630f14 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
@@ -3,11 +3,15 @@ import { describe, expect, it } from 'vitest';
import {
buildSavedWorkflowOptions,
+ getSavedWorkflowPickerOwnedQueryArg,
+ getSavedWorkflowPickerSharedQueryArg,
getSavedWorkflowDisplayState,
getSavedWorkflowListItemFromRecord,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
+ mergeSavedWorkflowPickerItems,
MISSING_WORKFLOW_OPTION_VALUE,
+ shouldFetchNextSavedWorkflowPickerPage,
} from './savedWorkflowFieldUtils';
const workflows: WorkflowRecordListItemWithThumbnailDTO[] = [
@@ -153,4 +157,40 @@ describe('savedWorkflowFieldUtils', () => {
compatibilityMessage: null,
});
});
+
+ it('queries owned/default workflows and shared public workflows separately', () => {
+ expect(getSavedWorkflowPickerOwnedQueryArg()).toMatchObject({
+ page: 0,
+ per_page: 50,
+ categories: ['user', 'default'],
+ is_public: undefined,
+ });
+ expect(getSavedWorkflowPickerSharedQueryArg()).toMatchObject({
+ page: 0,
+ per_page: 50,
+ categories: ['user'],
+ is_public: true,
+ });
+ });
+
+ it('merges paged owned and shared workflow picker results without duplicates', () => {
+ const sharedWorkflow = {
+ ...workflows[0],
+ workflow_id: 'workflow-shared',
+ name: 'Shared Workflow',
+ is_public: true,
+ };
+
+ expect(mergeSavedWorkflowPickerItems([workflows[0]], [workflows[1], sharedWorkflow], [workflows[0]])).toEqual([
+ workflows[0],
+ workflows[1],
+ sharedWorkflow,
+ ]);
+ });
+
+ it('fetches more workflow picker pages only when a query has another idle page', () => {
+ expect(shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: true, isFetching: false })).toBe(true);
+ expect(shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: false, isFetching: false })).toBe(false);
+ expect(shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: true, isFetching: true })).toBe(false);
+ });
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index a29b63bc3a8..d41e1bf0985 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -3,6 +3,7 @@ import { getWorkflowCallCompatibilityState } from 'features/workflowLibrary/util
import type { S, WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
+export const SAVED_WORKFLOW_PICKER_PAGE_SIZE = 50;
export type SavedWorkflowBadge = 'unsupported' | 'default' | 'shared';
type SavedWorkflowSelectionState =
@@ -18,6 +19,50 @@ export const buildSavedWorkflowOptions = (workflows: WorkflowRecordListItemWithT
}));
};
+const baseSavedWorkflowPickerQueryArg = {
+ page: 0,
+ per_page: SAVED_WORKFLOW_PICKER_PAGE_SIZE,
+ order_by: 'name',
+ direction: 'ASC',
+ query: '',
+ tags: [],
+ has_been_opened: undefined,
+} as const;
+
+export const getSavedWorkflowPickerOwnedQueryArg = () => ({
+ ...baseSavedWorkflowPickerQueryArg,
+ categories: ['user', 'default'],
+ is_public: undefined,
+});
+
+export const getSavedWorkflowPickerSharedQueryArg = () => ({
+ ...baseSavedWorkflowPickerQueryArg,
+ categories: ['user'],
+ is_public: true,
+});
+
+export const mergeSavedWorkflowPickerItems = (
+ ...workflowLists: WorkflowRecordListItemWithThumbnailDTO[][]
+): WorkflowRecordListItemWithThumbnailDTO[] => {
+ const workflowsById = new Map();
+ for (const workflows of workflowLists) {
+ for (const workflow of workflows) {
+ if (!workflowsById.has(workflow.workflow_id)) {
+ workflowsById.set(workflow.workflow_id, workflow);
+ }
+ }
+ }
+ return Array.from(workflowsById.values());
+};
+
+export const shouldFetchNextSavedWorkflowPickerPage = ({
+ hasNextPage,
+ isFetching,
+}: {
+ hasNextPage: boolean;
+ isFetching: boolean;
+}) => hasNextPage && !isFetching;
+
export const getSavedWorkflowSelectionState = (
workflows: WorkflowRecordListItemWithThumbnailDTO[],
workflowId: string,
From 5101326959d4a8cc28b198c40ae86b9dcb53fbbb Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Tue, 28 Apr 2026 21:06:57 -0500
Subject: [PATCH 082/100] Preserve workflow call batch field values
---
docs/contributing/call_saved_workflow.md | 1 +
.../session_processor/workflow_call_batch.py | 74 +++++++++++++++++--
.../workflow_call_runtime.py | 19 +++--
.../session_queue/session_queue_base.py | 6 +-
.../session_queue/session_queue_sqlite.py | 9 ++-
...st_session_queue_workflow_call_metadata.py | 59 ++++++++++++++-
.../app/services/test_workflow_call_batch.py | 43 ++++++++++-
.../app/services/workflow_call_test_utils.py | 7 +-
8 files changed, 198 insertions(+), 20 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 572ba4decb0..64f765ebcd4 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -362,6 +362,7 @@ Plain-English summary:
1. The child workflow is inspected before execution.
1. If the child contains supported batch inputs, that one call expands into multiple child executions instead of one.
1. Each expanded child execution becomes its own queue row.
+1. Each child queue row keeps the substituted batch `field_values`, matching ordinary batch queue rows.
1. Those child queue rows run independently.
1. The parent does not resume until all child queue rows for that call have finished.
1. Each child execution produces its own `workflow_return.collection`.
diff --git a/invokeai/app/services/session_processor/workflow_call_batch.py b/invokeai/app/services/session_processor/workflow_call_batch.py
index 54eeb27b7b6..d63efca81f4 100644
--- a/invokeai/app/services/session_processor/workflow_call_batch.py
+++ b/invokeai/app/services/session_processor/workflow_call_batch.py
@@ -4,6 +4,7 @@
import json
import random
from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
from typing import Any
from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator
@@ -14,6 +15,7 @@
from invokeai.app.services.session_queue.session_queue_common import (
Batch,
BatchDatum,
+ NodeFieldValue,
TooManySessionsError,
calc_session_count,
create_session_nfv_tuples,
@@ -44,6 +46,12 @@
CONNECTOR_INPUT_HANDLE = "in"
+@dataclass(frozen=True)
+class WorkflowCallChildSessionResult:
+ session: GraphExecutionState
+ field_values: list[NodeFieldValue] | None = None
+
+
def _is_mapping(value: Any) -> bool:
return isinstance(value, Mapping)
@@ -498,7 +506,7 @@ def _resolve_batch_items_from_inputs(
)
-def build_batch_child_workflow_sessions(
+def build_batch_child_workflow_session_results(
*,
parent_session: GraphExecutionState,
workflow: Mapping[str, Any],
@@ -582,16 +590,17 @@ def build_batch_child_workflow_sessions(
if calc_session_count(batch) > maximum_children:
raise TooManySessionsError("call_saved_workflow exceeds remaining queue capacity for child workflow executions")
- child_sessions: list[GraphExecutionState] = []
- for session_id, session_json, _field_values_json in create_session_nfv_tuples(batch, maximum_children):
+ child_session_results: list[WorkflowCallChildSessionResult] = []
+ for session_id, session_json, field_values_json in create_session_nfv_tuples(batch, maximum_children):
generated_session = GraphExecutionState.model_validate_json(session_json)
child_session = parent_session.create_child_workflow_execution_state(generated_session.graph, call_frame)
child_session.id = session_id
- child_sessions.append(child_session)
- return child_sessions
+ field_values = [NodeFieldValue.model_validate(field_value) for field_value in json.loads(field_values_json)]
+ child_session_results.append(WorkflowCallChildSessionResult(session=child_session, field_values=field_values))
+ return child_session_results
-def build_child_workflow_sessions(
+def build_batch_child_workflow_sessions(
*,
parent_session: GraphExecutionState,
workflow: Mapping[str, Any],
@@ -601,8 +610,32 @@ def build_child_workflow_sessions(
services: Any = None,
user_id: str | None = None,
) -> list[GraphExecutionState]:
+ return [
+ child_result.session
+ for child_result in build_batch_child_workflow_session_results(
+ parent_session=parent_session,
+ workflow=workflow,
+ workflow_inputs=workflow_inputs,
+ call_frame=call_frame,
+ maximum_children=maximum_children,
+ services=services,
+ user_id=user_id,
+ )
+ ]
+
+
+def build_child_workflow_session_results(
+ *,
+ parent_session: GraphExecutionState,
+ workflow: Mapping[str, Any],
+ workflow_inputs: Mapping[str, Any],
+ call_frame: WorkflowCallFrame,
+ maximum_children: int,
+ services: Any = None,
+ user_id: str | None = None,
+) -> list[WorkflowCallChildSessionResult]:
if workflow_contains_supported_batch_nodes(workflow):
- return build_batch_child_workflow_sessions(
+ return build_batch_child_workflow_session_results(
parent_session=parent_session,
workflow=workflow,
workflow_inputs=workflow_inputs,
@@ -615,4 +648,29 @@ def build_child_workflow_sessions(
mutable_workflow = copy.deepcopy(workflow)
apply_workflow_inputs_to_workflow(mutable_workflow, workflow_inputs)
child_graph = build_graph_from_workflow(mutable_workflow)
- return [parent_session.create_child_workflow_execution_state(child_graph, call_frame)]
+ child_session = parent_session.create_child_workflow_execution_state(child_graph, call_frame)
+ return [WorkflowCallChildSessionResult(session=child_session)]
+
+
+def build_child_workflow_sessions(
+ *,
+ parent_session: GraphExecutionState,
+ workflow: Mapping[str, Any],
+ workflow_inputs: Mapping[str, Any],
+ call_frame: WorkflowCallFrame,
+ maximum_children: int,
+ services: Any = None,
+ user_id: str | None = None,
+) -> list[GraphExecutionState]:
+ return [
+ child_result.session
+ for child_result in build_child_workflow_session_results(
+ parent_session=parent_session,
+ workflow=workflow,
+ workflow_inputs=workflow_inputs,
+ call_frame=call_frame,
+ maximum_children=maximum_children,
+ services=services,
+ user_id=user_id,
+ )
+ ]
diff --git a/invokeai/app/services/session_processor/workflow_call_runtime.py b/invokeai/app/services/session_processor/workflow_call_runtime.py
index ec2bc33e231..a9b442a6907 100644
--- a/invokeai/app/services/session_processor/workflow_call_runtime.py
+++ b/invokeai/app/services/session_processor/workflow_call_runtime.py
@@ -7,8 +7,8 @@
is_call_saved_workflow_dynamic_input,
)
from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
-from invokeai.app.services.session_processor.workflow_call_batch import build_child_workflow_sessions
-from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
+from invokeai.app.services.session_processor.workflow_call_batch import build_child_workflow_session_results
+from invokeai.app.services.session_queue.session_queue_common import NodeFieldValue, SessionQueueItem
from invokeai.app.services.shared.graph import GraphExecutionState
if TYPE_CHECKING:
@@ -36,7 +36,11 @@ def _collect_call_saved_workflow_inputs(
return workflow_inputs
@staticmethod
- def build_child_queue_item(queue_item: SessionQueueItem, child_session: GraphExecutionState) -> SessionQueueItem:
+ def build_child_queue_item(
+ queue_item: SessionQueueItem,
+ child_session: GraphExecutionState,
+ field_values: list[NodeFieldValue] | None = None,
+ ) -> SessionQueueItem:
workflow_call_execution = queue_item.session.waiting_workflow_call_execution
if workflow_call_execution is None:
raise ValueError("Parent queue item is missing active workflow call execution metadata.")
@@ -49,6 +53,7 @@ def build_child_queue_item(queue_item: SessionQueueItem, child_session: GraphExe
"parent_session_id": queue_item.session_id,
"root_item_id": root_item_id,
"workflow_call_depth": workflow_call_execution.depth,
+ "field_values": field_values,
}
if hasattr(queue_item, "model_copy"):
return queue_item.model_copy(update=child_updates)
@@ -70,7 +75,7 @@ def begin_workflow_call_boundary(
call_frame = queue_item.session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
workflow_inputs = self._collect_call_saved_workflow_inputs(invocation, queue_item)
- child_sessions = build_child_workflow_sessions(
+ child_session_results = build_child_workflow_session_results(
parent_session=queue_item.session,
workflow=workflow_record.workflow.model_dump(),
workflow_inputs=workflow_inputs,
@@ -79,6 +84,7 @@ def begin_workflow_call_boundary(
services=self._session_runner._services,
user_id=getattr(queue_item, "user_id", None),
)
+ child_sessions = [child_result.session for child_result in child_session_results]
if len(child_sessions) > remaining_queue_capacity:
raise ValueError("call_saved_workflow exceeds remaining queue capacity for child workflow executions")
queue_item.session.begin_waiting_on_workflow_call(call_frame)
@@ -87,10 +93,11 @@ def begin_workflow_call_boundary(
enqueued_child_item_ids: list[int] = []
try:
self._session_runner._services.session_queue.set_queue_item_session(queue_item.item_id, queue_item.session)
- for child_session in child_sessions:
+ for child_result in child_session_results:
child_queue_item = self._session_runner._services.session_queue.enqueue_workflow_call_child(
parent_queue_item=queue_item,
- child_session=child_session,
+ child_session=child_result.session,
+ field_values=child_result.field_values,
)
enqueued_child_item_ids.append(child_queue_item.item_id)
self._session_runner._services.session_queue.suspend_queue_item(queue_item.item_id)
diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py
index 28bd6ba11c0..79ea1d342e3 100644
--- a/invokeai/app/services/session_queue/session_queue_base.py
+++ b/invokeai/app/services/session_queue/session_queue_base.py
@@ -16,6 +16,7 @@
IsEmptyResult,
IsFullResult,
ItemIdsResult,
+ NodeFieldValue,
PruneResult,
RetryItemsResult,
SessionQueueCountsByDestination,
@@ -206,7 +207,10 @@ def set_queue_item_session(self, item_id: int, session: GraphExecutionState) ->
@abstractmethod
def enqueue_workflow_call_child(
- self, parent_queue_item: SessionQueueItem, child_session: GraphExecutionState
+ self,
+ parent_queue_item: SessionQueueItem,
+ child_session: GraphExecutionState,
+ field_values: list[NodeFieldValue] | None = None,
) -> SessionQueueItem:
"""Enqueues a child workflow execution linked to a suspended parent queue item."""
pass
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index ee33654def5..cf6ea3b5506 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -23,6 +23,7 @@
IsEmptyResult,
IsFullResult,
ItemIdsResult,
+ NodeFieldValue,
PruneResult,
RetryItemsResult,
SessionQueueCountsByDestination,
@@ -815,13 +816,17 @@ def set_queue_item_session(self, item_id: int, session: GraphExecutionState) ->
return self.get_queue_item(item_id)
def enqueue_workflow_call_child(
- self, parent_queue_item: SessionQueueItem, child_session: GraphExecutionState
+ self,
+ parent_queue_item: SessionQueueItem,
+ child_session: GraphExecutionState,
+ field_values: list[NodeFieldValue] | None = None,
) -> SessionQueueItem:
workflow_call_execution = parent_queue_item.session.waiting_workflow_call_execution
if workflow_call_execution is None:
raise ValueError("Parent queue item is missing active workflow call execution metadata.")
session_json = child_session.model_dump_json(warnings=False, exclude_none=True)
+ field_values_json = json.dumps(field_values, default=to_jsonable_python) if field_values is not None else None
root_item_id = parent_queue_item.root_item_id or parent_queue_item.item_id
with self._db.transaction() as cursor:
@@ -853,7 +858,7 @@ def enqueue_workflow_call_child(
session_json,
child_session.id,
parent_queue_item.batch_id,
- None,
+ field_values_json,
parent_queue_item.priority,
None,
parent_queue_item.origin,
diff --git a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
index 9fd5a389a86..ec00df4e7e2 100644
--- a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
+++ b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
@@ -7,7 +7,7 @@
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
from invokeai.app.services.events.events_common import QueueItemsRetriedEvent, QueueItemStatusChangedEvent
from invokeai.app.services.invoker import Invoker
-from invokeai.app.services.session_queue.session_queue_common import SessionQueueItemNotFoundError
+from invokeai.app.services.session_queue.session_queue_common import NodeFieldValue, SessionQueueItemNotFoundError
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from tests.test_nodes import TestEventService
@@ -144,6 +144,63 @@ def test_enqueue_workflow_call_child_persists_pending_child_queue_item(session_q
assert child_queue_item.session_id == child_session.id
+def test_enqueue_workflow_call_child_persists_batch_field_values(session_queue: SqliteSessionQueue) -> None:
+ parent_graph = Graph()
+ parent_graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ parent_session = GraphExecutionState(graph=parent_graph)
+ invocation = parent_session.next()
+ assert isinstance(invocation, CallSavedWorkflowInvocation)
+
+ frame = parent_session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
+ child_session = parent_session.create_child_workflow_execution_state(Graph(), frame)
+ parent_session.begin_waiting_on_workflow_call(frame)
+ parent_session.attach_waiting_workflow_call_child_session(child_session)
+
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id,
+ session,
+ session_id,
+ batch_id,
+ field_values,
+ priority,
+ workflow,
+ origin,
+ destination,
+ retried_from_item_id,
+ user_id,
+ status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ parent_session.model_dump_json(warnings=False),
+ parent_session.id,
+ str(uuid.uuid4()),
+ None,
+ 0,
+ None,
+ None,
+ None,
+ None,
+ "user-1",
+ "in_progress",
+ ),
+ )
+ parent_item_id = cursor.lastrowid
+
+ child_queue_item = session_queue.enqueue_workflow_call_child(
+ parent_queue_item=session_queue.get_queue_item(parent_item_id),
+ child_session=child_session,
+ field_values=[NodeFieldValue(node_path="target", field_name="value", value=2)],
+ )
+
+ assert child_queue_item.field_values == [NodeFieldValue(node_path="target", field_name="value", value=2)]
+
+
def test_suspend_and_enqueue_child_emit_waiting_then_pending_status_events(
session_queue: SqliteSessionQueue, event_bus: TestEventService
) -> None:
diff --git a/tests/app/services/test_workflow_call_batch.py b/tests/app/services/test_workflow_call_batch.py
index 503f0a78a3b..ea09d9773ac 100644
--- a/tests/app/services/test_workflow_call_batch.py
+++ b/tests/app/services/test_workflow_call_batch.py
@@ -2,7 +2,10 @@
import pytest
-from invokeai.app.services.session_processor.workflow_call_batch import build_child_workflow_sessions
+from invokeai.app.services.session_processor.workflow_call_batch import (
+ build_child_workflow_session_results,
+ build_child_workflow_sessions,
+)
from invokeai.app.services.shared.graph import Graph, GraphExecutionState, WorkflowCallFrame
from invokeai.app.services.shared.workflow_graph_builder import UnsupportedWorkflowNodeError
@@ -112,6 +115,44 @@ def test_build_child_workflow_sessions_expands_direct_integer_batch() -> None:
assert [child_session.graph.nodes["target"].value for child_session in child_sessions] == [2, 4, 6]
+def test_build_child_workflow_session_results_preserves_batch_field_values() -> None:
+ workflow = _workflow_dump(
+ nodes=[
+ _invocation_node(
+ "batch",
+ "integer_batch",
+ {"integers": {"value": [2, 4]}, "batch_group_id": {"value": "None"}},
+ ),
+ _invocation_node("target", "integer", {"value": {"value": 0}}),
+ _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ ],
+ edges=[
+ {
+ "id": "edge-batch-target",
+ "type": "default",
+ "source": "batch",
+ "sourceHandle": "integers",
+ "target": "target",
+ "targetHandle": "value",
+ }
+ ],
+ )
+
+ child_results = build_child_workflow_session_results(
+ parent_session=GraphExecutionState(graph=Graph()),
+ workflow=workflow,
+ workflow_inputs={},
+ call_frame=_call_frame(),
+ maximum_children=10,
+ )
+
+ assert [result.session.graph.nodes["target"].value for result in child_results] == [2, 4]
+ assert [
+ [(field_value.node_path, field_value.field_name, field_value.value) for field_value in result.field_values or []]
+ for result in child_results
+ ] == [[("target", "value", 2)], [("target", "value", 4)]]
+
+
def test_build_child_workflow_sessions_expands_direct_integer_batch_into_collection_input() -> None:
workflow = _workflow_dump(
nodes=[
diff --git a/tests/app/services/workflow_call_test_utils.py b/tests/app/services/workflow_call_test_utils.py
index a23b34bea5b..d6f6031affe 100644
--- a/tests/app/services/workflow_call_test_utils.py
+++ b/tests/app/services/workflow_call_test_utils.py
@@ -1006,7 +1006,7 @@ def cancel_workflow_call_children(
self.canceled_item_ids.append(item_id)
return canceled_item_ids
- def enqueue_workflow_call_child(self, parent_queue_item, child_session):
+ def enqueue_workflow_call_child(self, parent_queue_item, child_session, field_values=None):
if self.fail_enqueue_after is not None and len(self.enqueued_child_item_ids) >= self.fail_enqueue_after:
raise RuntimeError("Injected child enqueue failure")
workflow_call_execution = parent_queue_item.session.waiting_workflow_call_execution
@@ -1026,6 +1026,7 @@ def enqueue_workflow_call_child(self, parent_queue_item, child_session):
"origin": getattr(parent_queue_item, "origin", None),
"destination": getattr(parent_queue_item, "destination", None),
"priority": getattr(parent_queue_item, "priority", 0),
+ "field_values": field_values,
"workflow_call_id": workflow_call_execution.id if workflow_call_execution is not None else None,
"parent_item_id": parent_queue_item.item_id,
"parent_session_id": parent_queue_item.session_id,
@@ -1866,6 +1867,10 @@ def test_run_completes_call_saved_workflow_with_batched_child_returns(monkeypatc
]
assert session_queue.enqueued_child_item_ids == [100, 101, 102]
+ assert [
+ [(field_value.node_path, field_value.field_name, field_value.value) for field_value in session_queue.items[item_id].field_values]
+ for item_id in session_queue.enqueued_child_item_ids
+ ] == [[("child-int", "value", 2)], [("child-int", "value", 4)], [("child-int", "value", 6)]]
assert session_queue.completed_item_ids == [100, 101, 102, 1]
assert len(parent_outputs) == 1
assert parent_outputs[0].collection == [2, 4, 6]
From 86ddbf28fc48dba2960780d4aa7fa0e9e9fb6e85 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 06:02:28 -0500
Subject: [PATCH 083/100] chore: linting
---
docs/contributing/call_saved_workflow.md | 7 ++++---
.../SavedWorkflowFieldInputComponent.tsx | 21 +++++--------------
.../inputs/savedWorkflowFieldUtils.test.ts | 17 +++++++++------
.../fields/inputs/savedWorkflowFieldUtils.ts | 6 +++---
.../nodes/util/graph/buildNodesGraph.test.ts | 5 +++--
.../app/services/test_workflow_call_batch.py | 5 ++++-
.../app/services/workflow_call_test_utils.py | 5 ++++-
7 files changed, 34 insertions(+), 32 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 64f765ebcd4..07d1f8bd28a 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -101,7 +101,8 @@ Implemented runtime scaffolding:
- `_on_after_run_session()` no longer completes queue items whose sessions are incomplete but waiting.
- Dynamic call arguments now execute end-to-end in the current runner path:
- literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node at graph-build time
- - stale hidden `workflow_inputs` values from recalled graphs are ignored unless a matching current dynamic field exists
+ - stale hidden `workflow_inputs` values from recalled graphs are ignored unless a matching current dynamic field
+ exists
- connected dynamic values are accepted as special call-boundary edges and are resolved from parent results at runtime
- both are validated against the child workflow's exposed form interface before being applied to the child graph
- Queue lifecycle semantics now exist for workflow-call chains:
@@ -413,8 +414,8 @@ Cancel behavior:
- canceling a waiting parent cancels descendant child rows
- canceling a child row cancels waiting ancestors
- cancelation should stay cancelation; it should not be rewritten into ordinary failure semantics
-- startup recovery cancels any interrupted `in_progress` or `waiting` workflow-call chain, including pending descendants,
- so a restart cannot leave a suspended parent waiting on a child row that will never report back
+- startup recovery cancels any interrupted `in_progress` or `waiting` workflow-call chain, including pending
+ descendants, so a restart cannot leave a suspended parent waiting on a child row that will never report back
Retry behavior:
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
index 4892821f838..d0ee0120e74 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
@@ -11,10 +11,10 @@ import { useGetWorkflowQuery, useListWorkflowsInfiniteInfiniteQuery } from 'serv
import {
buildSavedWorkflowOptions,
- getSavedWorkflowPickerOwnedQueryArg,
- getSavedWorkflowPickerSharedQueryArg,
getSavedWorkflowDisplayState,
getSavedWorkflowListItemFromRecord,
+ getSavedWorkflowPickerOwnedQueryArg,
+ getSavedWorkflowPickerSharedQueryArg,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
mergeSavedWorkflowPickerItems,
@@ -104,24 +104,13 @@ const SavedWorkflowFieldInputComponent = (
[dispatch, field.name, nodeId]
);
const onMenuScrollToBottom = useCallback(() => {
- if (
- shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: hasNextOwnedPage, isFetching: isOwnedFetching })
- ) {
+ if (shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: hasNextOwnedPage, isFetching: isOwnedFetching })) {
fetchNextOwnedPage();
}
- if (
- shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: hasNextSharedPage, isFetching: isSharedFetching })
- ) {
+ if (shouldFetchNextSavedWorkflowPickerPage({ hasNextPage: hasNextSharedPage, isFetching: isSharedFetching })) {
fetchNextSharedPage();
}
- }, [
- fetchNextOwnedPage,
- fetchNextSharedPage,
- hasNextOwnedPage,
- hasNextSharedPage,
- isOwnedFetching,
- isSharedFetching,
- ]);
+ }, [fetchNextOwnedPage, fetchNextSharedPage, hasNextOwnedPage, hasNextSharedPage, isOwnedFetching, isSharedFetching]);
const noOptionsMessage = useCallback(() => t('nodes.noMatchingWorkflows'), [t]);
const isLoading = isOwnedLoading || isSharedLoading;
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
index a519d630f14..d0abe34baba 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
import {
buildSavedWorkflowOptions,
- getSavedWorkflowPickerOwnedQueryArg,
- getSavedWorkflowPickerSharedQueryArg,
getSavedWorkflowDisplayState,
getSavedWorkflowListItemFromRecord,
+ getSavedWorkflowPickerOwnedQueryArg,
+ getSavedWorkflowPickerSharedQueryArg,
getSavedWorkflowSelectionOption,
getSavedWorkflowSelectionState,
mergeSavedWorkflowPickerItems,
@@ -174,16 +174,21 @@ describe('savedWorkflowFieldUtils', () => {
});
it('merges paged owned and shared workflow picker results without duplicates', () => {
+ const ownedWorkflow = workflows[0];
+ const defaultWorkflow = workflows[1];
+ if (!ownedWorkflow || !defaultWorkflow) {
+ throw new Error('Expected workflow fixtures');
+ }
const sharedWorkflow = {
- ...workflows[0],
+ ...ownedWorkflow,
workflow_id: 'workflow-shared',
name: 'Shared Workflow',
is_public: true,
};
- expect(mergeSavedWorkflowPickerItems([workflows[0]], [workflows[1], sharedWorkflow], [workflows[0]])).toEqual([
- workflows[0],
- workflows[1],
+ expect(mergeSavedWorkflowPickerItems([ownedWorkflow], [defaultWorkflow, sharedWorkflow], [ownedWorkflow])).toEqual([
+ ownedWorkflow,
+ defaultWorkflow,
sharedWorkflow,
]);
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index d41e1bf0985..95476c5939a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -25,19 +25,19 @@ const baseSavedWorkflowPickerQueryArg = {
order_by: 'name',
direction: 'ASC',
query: '',
- tags: [],
+ tags: [] as string[],
has_been_opened: undefined,
} as const;
export const getSavedWorkflowPickerOwnedQueryArg = () => ({
...baseSavedWorkflowPickerQueryArg,
- categories: ['user', 'default'],
+ categories: ['user', 'default'] as ('user' | 'default')[],
is_public: undefined,
});
export const getSavedWorkflowPickerSharedQueryArg = () => ({
...baseSavedWorkflowPickerQueryArg,
- categories: ['user'],
+ categories: ['user'] as ('user' | 'default')[],
is_public: true,
});
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
index a75d06019c7..046487e40e7 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.test.ts
@@ -199,9 +199,10 @@ describe('buildNodesGraph', () => {
} as never;
const graph = buildNodesGraph(rootState, templatesWithWorkflowInputs);
+ const graphNode = graph.nodes[node.id] as { workflow_id: string; workflow_inputs: Record };
- expect(graph.nodes[node.id].workflow_id).toBe('');
- expect(graph.nodes[node.id].workflow_inputs).toEqual({});
+ expect(graphNode.workflow_id).toBe('');
+ expect(graphNode.workflow_inputs).toEqual({});
});
it('flattens a single connector to one direct execution edge', () => {
diff --git a/tests/app/services/test_workflow_call_batch.py b/tests/app/services/test_workflow_call_batch.py
index ea09d9773ac..61fa35c17d4 100644
--- a/tests/app/services/test_workflow_call_batch.py
+++ b/tests/app/services/test_workflow_call_batch.py
@@ -148,7 +148,10 @@ def test_build_child_workflow_session_results_preserves_batch_field_values() ->
assert [result.session.graph.nodes["target"].value for result in child_results] == [2, 4]
assert [
- [(field_value.node_path, field_value.field_name, field_value.value) for field_value in result.field_values or []]
+ [
+ (field_value.node_path, field_value.field_name, field_value.value)
+ for field_value in result.field_values or []
+ ]
for result in child_results
] == [[("target", "value", 2)], [("target", "value", 4)]]
diff --git a/tests/app/services/workflow_call_test_utils.py b/tests/app/services/workflow_call_test_utils.py
index d6f6031affe..ffa277fe83c 100644
--- a/tests/app/services/workflow_call_test_utils.py
+++ b/tests/app/services/workflow_call_test_utils.py
@@ -1868,7 +1868,10 @@ def test_run_completes_call_saved_workflow_with_batched_child_returns(monkeypatc
assert session_queue.enqueued_child_item_ids == [100, 101, 102]
assert [
- [(field_value.node_path, field_value.field_name, field_value.value) for field_value in session_queue.items[item_id].field_values]
+ [
+ (field_value.node_path, field_value.field_name, field_value.value)
+ for field_value in session_queue.items[item_id].field_values
+ ]
for item_id in session_queue.enqueued_child_item_ids
] == [[("child-int", "value", 2)], [("child-int", "value", 4)], [("child-int", "value", 6)]]
assert session_queue.completed_item_ids == [100, 101, 102, 1]
From 8de8732dc9ae4da323f3ccf11f86224afb4c7024 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 06:47:50 -0500
Subject: [PATCH 084/100] Harden workflow-call queue cancellation semantics
---
docs/contributing/call_saved_workflow.md | 6 +
.../session_queue/session_queue_sqlite.py | 35 ++-
invokeai/app/services/shared/README.md | 4 +-
...st_session_queue_workflow_call_metadata.py | 223 ++++++++++++++++++
4 files changed, 262 insertions(+), 6 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 07d1f8bd28a..d807b9a0cd8 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -111,8 +111,11 @@ Implemented runtime scaffolding:
- child failure fails the suspended parent and cascades upward through any waiting parent chain
- canceling a parent cancels its descendant child chain
- canceling a child cancels the waiting parent chain upward
+ - canceling remaining siblings after a batched child failure also cancels descendants of those sibling rows
- deleting any queue row in a workflow-call chain deletes the full chain to avoid leaving orphaned parent or child
rows behind
+ - `cancel_all_except_current` and `delete_all_except_current` preserve the active queue item plus its workflow-call
+ ancestors and descendants; unrelated waiting chains are still canceled or deleted
- retry is root-oriented rather than child-oriented; child queue rows should not be directly retried from the UI
- the current UI policy is:
- child queue rows keep `Cancel`
@@ -282,6 +285,9 @@ The current queue-visible implementation uses the following lifecycle contract:
- cancel operations are chain-aware:
- canceling a waiting parent cancels descendants
- canceling a child cancels waiting ancestors
+ - canceling batched siblings after one child fails includes nested descendants of those siblings
+ - bulk "all except current" actions preserve the active queue item and its parent/child chain, not just the single
+ `in_progress` row
- retry operations are root-aware:
- retrying a root queue item creates a new root execution
- retrying a child queue item should be normalized to the root by backend code
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index cf6ea3b5506..8ed50780edc 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -1,7 +1,7 @@
import asyncio
import json
import sqlite3
-from typing import Optional, Union, cast
+from typing import Any, Optional, Union, cast
from pydantic_core import to_jsonable_python
@@ -385,6 +385,12 @@ def _get_workflow_call_chain_item_ids(self, item_id: int) -> list[int]:
deduped_chain_item_ids = list(dict.fromkeys(chain_item_ids))
return deduped_chain_item_ids
+ def _get_current_workflow_call_chain_item_ids(self, queue_id: str) -> set[int]:
+ current_queue_item = self.get_current(queue_id)
+ if current_queue_item is None:
+ return set()
+ return set(self._get_workflow_call_chain_item_ids(current_queue_item.item_id))
+
def is_empty(self, queue_id: str) -> IsEmptyResult:
with self._db.transaction() as cursor:
cursor.execute(
@@ -470,7 +476,7 @@ def prune(self, queue_id: str, user_id: Optional[str] = None) -> PruneResult:
)
{user_filter}
"""
- params = [queue_id]
+ params: list[Any] = [queue_id]
if user_id is not None:
params.append(user_id)
@@ -678,18 +684,25 @@ def delete_by_destination(
return DeleteByDestinationResult(deleted=count)
def delete_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> DeleteAllExceptCurrentResult:
+ current_chain_item_ids = self._get_current_workflow_call_chain_item_ids(queue_id)
with self._db.transaction() as cursor:
# Build WHERE clause with optional user_id filter
user_filter = "AND user_id = ?" if user_id is not None else ""
+ current_chain_filter = ""
+ if current_chain_item_ids:
+ placeholders = ", ".join(["?" for _ in current_chain_item_ids])
+ current_chain_filter = f"AND item_id NOT IN ({placeholders})"
where = f"""--sql
WHERE
queue_id == ?
- AND status == 'pending'
+ AND status IN ('pending', 'waiting')
{user_filter}
+ {current_chain_filter}
"""
- params = [queue_id]
+ params: list[Any] = [queue_id]
if user_id is not None:
params.append(user_id)
+ params.extend(current_chain_item_ids)
cursor.execute(
f"""--sql
@@ -747,18 +760,25 @@ def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult:
return CancelByQueueIDResult(canceled=count)
def cancel_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> CancelAllExceptCurrentResult:
+ current_chain_item_ids = self._get_current_workflow_call_chain_item_ids(queue_id)
with self._db.transaction() as cursor:
# Build WHERE clause with optional user_id filter
user_filter = "AND user_id = ?" if user_id is not None else ""
+ current_chain_filter = ""
+ if current_chain_item_ids:
+ placeholders = ", ".join(["?" for _ in current_chain_item_ids])
+ current_chain_filter = f"AND item_id NOT IN ({placeholders})"
where = f"""--sql
WHERE
queue_id == ?
- AND status == 'pending'
+ AND status IN ('pending', 'waiting')
{user_filter}
+ {current_chain_filter}
"""
params = [queue_id]
if user_id is not None:
params.append(user_id)
+ params.extend(current_chain_item_ids)
cursor.execute(
f"""--sql
@@ -895,6 +915,11 @@ def cancel_workflow_call_children(
(workflow_call_id,),
)
item_ids = [row[0] for row in cast(list[sqlite3.Row], cursor.fetchall())]
+ item_ids_with_descendants: list[int] = []
+ for item_id in item_ids:
+ item_ids_with_descendants.append(item_id)
+ item_ids_with_descendants.extend(self._get_workflow_call_descendant_ids(item_id))
+ item_ids = list(dict.fromkeys(item_ids_with_descendants))
canceled_item_ids: list[int] = []
for item_id in item_ids:
if item_id in exclude_item_ids:
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index bf5d97bdf8b..fd5558e7727 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -155,7 +155,9 @@ Workflow-call note:
nodes; the parent resumes only after all expected child rows complete
- child failure fails the waiting parent and can cascade upward through ancestors
- failing child rows cancel their remaining workflow-call siblings before the parent is failed
- - cancelation is chain-aware across parents and children
+ - cancelation is chain-aware across parents and children, including nested descendants of batched siblings
+ - "all except current" queue actions preserve the active current item plus its workflow-call chain, while still
+ canceling or deleting unrelated waiting chains
- startup recovery cancels interrupted `in_progress` or `waiting` workflow-call chains, including pending descendants
- deleting a workflow-call queue row currently deletes the whole parent/child chain rather than leaving orphaned rows
behind
diff --git a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
index ec00df4e7e2..c8e9e87bf31 100644
--- a/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
+++ b/tests/app/services/session_queue/test_session_queue_workflow_call_metadata.py
@@ -27,6 +27,67 @@ def event_bus(mock_invoker: Invoker) -> TestEventService:
return mock_invoker.services.events
+def _insert_queue_item(
+ session_queue: SqliteSessionQueue,
+ *,
+ session: GraphExecutionState,
+ status: str,
+ queue_id: str = "default",
+ batch_id: str | None = None,
+ user_id: str = "user-1",
+ workflow_call_id: str | None = None,
+ parent_item_id: int | None = None,
+ parent_session_id: str | None = None,
+ root_item_id: int | None = None,
+ workflow_call_depth: int | None = None,
+) -> int:
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id,
+ session,
+ session_id,
+ batch_id,
+ field_values,
+ priority,
+ workflow,
+ origin,
+ destination,
+ retried_from_item_id,
+ user_id,
+ workflow_call_id,
+ parent_item_id,
+ parent_session_id,
+ root_item_id,
+ workflow_call_depth,
+ status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ queue_id,
+ session.model_dump_json(warnings=False),
+ session.id,
+ batch_id or str(uuid.uuid4()),
+ None,
+ 0,
+ None,
+ None,
+ None,
+ None,
+ user_id,
+ workflow_call_id,
+ parent_item_id,
+ parent_session_id,
+ root_item_id,
+ workflow_call_depth,
+ status,
+ ),
+ )
+ return cursor.lastrowid
+
+
def test_get_queue_item_round_trips_workflow_call_metadata(session_queue: SqliteSessionQueue) -> None:
session = GraphExecutionState(graph=Graph())
session_json = session.model_dump_json(warnings=False)
@@ -760,6 +821,168 @@ def test_cancel_queue_item_cascade_emits_canceled_events_for_waiting_parent_and_
assert canceled_events[-1].queue_status.in_progress == 0
+def test_cancel_workflow_call_children_cancels_nested_descendants(session_queue: SqliteSessionQueue) -> None:
+ root_session = GraphExecutionState(graph=Graph())
+ waiting_child_session = GraphExecutionState(graph=Graph())
+ nested_child_session = GraphExecutionState(graph=Graph())
+ sibling_session = GraphExecutionState(graph=Graph())
+
+ root_item_id = _insert_queue_item(session_queue, session=root_session, status="waiting")
+ waiting_child_item_id = _insert_queue_item(
+ session_queue,
+ session=waiting_child_session,
+ status="waiting",
+ workflow_call_id="workflow-call-1",
+ parent_item_id=root_item_id,
+ parent_session_id=root_session.id,
+ root_item_id=root_item_id,
+ workflow_call_depth=1,
+ )
+ nested_child_item_id = _insert_queue_item(
+ session_queue,
+ session=nested_child_session,
+ status="in_progress",
+ workflow_call_id="workflow-call-2",
+ parent_item_id=waiting_child_item_id,
+ parent_session_id=waiting_child_session.id,
+ root_item_id=root_item_id,
+ workflow_call_depth=2,
+ )
+ sibling_item_id = _insert_queue_item(
+ session_queue,
+ session=sibling_session,
+ status="pending",
+ workflow_call_id="workflow-call-1",
+ parent_item_id=root_item_id,
+ parent_session_id=root_session.id,
+ root_item_id=root_item_id,
+ workflow_call_depth=1,
+ )
+
+ canceled_item_ids = session_queue.cancel_workflow_call_children("workflow-call-1")
+
+ assert canceled_item_ids == [waiting_child_item_id, nested_child_item_id, sibling_item_id]
+ assert session_queue.get_queue_item(waiting_child_item_id).status == "canceled"
+ assert session_queue.get_queue_item(nested_child_item_id).status == "canceled"
+ assert session_queue.get_queue_item(sibling_item_id).status == "canceled"
+
+
+def test_cancel_all_except_current_cancels_waiting_chains_outside_current_chain(
+ session_queue: SqliteSessionQueue,
+) -> None:
+ current_parent_session = GraphExecutionState(graph=Graph())
+ current_child_session = GraphExecutionState(graph=Graph())
+ other_parent_session = GraphExecutionState(graph=Graph())
+ other_child_session = GraphExecutionState(graph=Graph())
+
+ current_parent_item_id = _insert_queue_item(session_queue, session=current_parent_session, status="waiting")
+ current_child_item_id = _insert_queue_item(
+ session_queue,
+ session=current_child_session,
+ status="in_progress",
+ workflow_call_id="workflow-call-current",
+ parent_item_id=current_parent_item_id,
+ parent_session_id=current_parent_session.id,
+ root_item_id=current_parent_item_id,
+ workflow_call_depth=1,
+ )
+ other_parent_item_id = _insert_queue_item(session_queue, session=other_parent_session, status="waiting")
+ other_child_item_id = _insert_queue_item(
+ session_queue,
+ session=other_child_session,
+ status="pending",
+ workflow_call_id="workflow-call-other",
+ parent_item_id=other_parent_item_id,
+ parent_session_id=other_parent_session.id,
+ root_item_id=other_parent_item_id,
+ workflow_call_depth=1,
+ )
+
+ result = session_queue.cancel_all_except_current("default")
+
+ assert result.canceled == 2
+ assert session_queue.get_queue_item(current_parent_item_id).status == "waiting"
+ assert session_queue.get_queue_item(current_child_item_id).status == "in_progress"
+ assert session_queue.get_queue_item(other_parent_item_id).status == "canceled"
+ assert session_queue.get_queue_item(other_child_item_id).status == "canceled"
+
+
+def test_delete_all_except_current_deletes_waiting_chains_outside_current_chain(
+ session_queue: SqliteSessionQueue,
+) -> None:
+ current_parent_session = GraphExecutionState(graph=Graph())
+ current_child_session = GraphExecutionState(graph=Graph())
+ other_parent_session = GraphExecutionState(graph=Graph())
+ other_child_session = GraphExecutionState(graph=Graph())
+
+ current_parent_item_id = _insert_queue_item(session_queue, session=current_parent_session, status="waiting")
+ current_child_item_id = _insert_queue_item(
+ session_queue,
+ session=current_child_session,
+ status="in_progress",
+ workflow_call_id="workflow-call-current",
+ parent_item_id=current_parent_item_id,
+ parent_session_id=current_parent_session.id,
+ root_item_id=current_parent_item_id,
+ workflow_call_depth=1,
+ )
+ other_parent_item_id = _insert_queue_item(session_queue, session=other_parent_session, status="waiting")
+ other_child_item_id = _insert_queue_item(
+ session_queue,
+ session=other_child_session,
+ status="pending",
+ workflow_call_id="workflow-call-other",
+ parent_item_id=other_parent_item_id,
+ parent_session_id=other_parent_session.id,
+ root_item_id=other_parent_item_id,
+ workflow_call_depth=1,
+ )
+
+ result = session_queue.delete_all_except_current("default")
+
+ assert result.deleted == 2
+ assert session_queue.get_queue_item(current_parent_item_id).status == "waiting"
+ assert session_queue.get_queue_item(current_child_item_id).status == "in_progress"
+ with pytest.raises(SessionQueueItemNotFoundError):
+ session_queue.get_queue_item(other_parent_item_id)
+ with pytest.raises(SessionQueueItemNotFoundError):
+ session_queue.get_queue_item(other_child_item_id)
+
+
+def test_cancel_by_queue_id_cancels_current_workflow_call_descendants(session_queue: SqliteSessionQueue) -> None:
+ parent_session = GraphExecutionState(graph=Graph())
+ child_session = GraphExecutionState(graph=Graph())
+ nested_child_session = GraphExecutionState(graph=Graph())
+
+ parent_item_id = _insert_queue_item(session_queue, session=parent_session, status="waiting")
+ child_item_id = _insert_queue_item(
+ session_queue,
+ session=child_session,
+ status="in_progress",
+ workflow_call_id="workflow-call-1",
+ parent_item_id=parent_item_id,
+ parent_session_id=parent_session.id,
+ root_item_id=parent_item_id,
+ workflow_call_depth=1,
+ )
+ nested_child_item_id = _insert_queue_item(
+ session_queue,
+ session=nested_child_session,
+ status="pending",
+ workflow_call_id="workflow-call-2",
+ parent_item_id=child_item_id,
+ parent_session_id=child_session.id,
+ root_item_id=parent_item_id,
+ workflow_call_depth=2,
+ )
+
+ session_queue.cancel_by_queue_id("default")
+
+ assert session_queue.get_queue_item(parent_item_id).status == "canceled"
+ assert session_queue.get_queue_item(child_item_id).status == "canceled"
+ assert session_queue.get_queue_item(nested_child_item_id).status == "canceled"
+
+
def test_retry_items_by_id_retries_root_once_for_child_chain_item(session_queue: SqliteSessionQueue) -> None:
root_session = GraphExecutionState(graph=Graph())
child_session = GraphExecutionState(graph=Graph())
From 51fc375526a5c4ba50245d3e4cd51846c0518abe Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 07:21:12 -0500
Subject: [PATCH 085/100] Harden saved workflow caller UI state
---
docs/contributing/call_saved_workflow.md | 6 ++
invokeai/app/services/shared/README.md | 8 +-
.../SavedWorkflowFieldInputComponent.tsx | 30 ++++++--
.../inputs/savedWorkflowFieldUtils.test.ts | 6 +-
.../fields/inputs/savedWorkflowFieldUtils.ts | 7 +-
.../features/nodes/store/nodesSlice.test.ts | 77 ++++++++++++++++++-
.../src/features/nodes/store/nodesSlice.ts | 13 ++++
7 files changed, 131 insertions(+), 16 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index d807b9a0cd8..5a6986148ba 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -103,6 +103,9 @@ Implemented runtime scaffolding:
- literal dynamic values are serialized into a hidden `workflow_inputs` payload on the parent node at graph-build time
- stale hidden `workflow_inputs` values from recalled graphs are ignored unless a matching current dynamic field
exists
+ - existing dynamic input values are preserved across refresh only while the exposed field type remains compatible; if
+ the selected child workflow changes the exposed field type at the same node/field path, the caller input resets to
+ the child workflow's current initial value
- connected dynamic values are accepted as special call-boundary edges and are resolved from parent results at runtime
- both are validated against the child workflow's exposed form interface before being applied to the child graph
- Queue lifecycle semantics now exist for workflow-call chains:
@@ -219,6 +222,9 @@ graph but are not exposed by the form are not part of the public call interface.
`CallSavedWorkflowInvocation` exposes dynamic inputs in the editor based on the selected workflow's callable interface.
+The saved-workflow picker sends typed search text to the workflow-list endpoint. This keeps large workflow libraries
+discoverable even when the desired workflow has not already been loaded into the combobox pages.
+
Each dynamic input must have:
- a stable external handle name
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index fd5558e7727..3c047676efa 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -64,8 +64,12 @@ Runs a sequence of checks:
`saved_workflow_input::{childNodeId}::{childFieldName}` as part of its temporary call-boundary contract.
- Those handles are allowed through graph validation even though they are not static Python model fields on the
invocation class.
- - Runtime later validates them against the selected child workflow's exposed callable interface before applying
- values to the child graph.
+ - Runtime later validates them against the selected child workflow's exposed callable interface before applying
+ values to the child graph.
+ - The editor preserves dynamic caller values only while the exposed field type remains compatible; type drift at the
+ same child node/field path resets to the selected workflow's current initial value.
+ - Saved-workflow picker search is server-backed so large workflow libraries do not require scrolling every page before
+ selecting a workflow by name.
1. **Iterator / collector structure** Enforce special rules:
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
index d0ee0120e74..538eb4aac0c 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx
@@ -5,7 +5,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { fieldStringValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { SavedWorkflowFieldInputInstance, SavedWorkflowFieldInputTemplate } from 'features/nodes/types/field';
-import { memo, useCallback, useMemo } from 'react';
+import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetWorkflowQuery, useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
@@ -23,13 +23,6 @@ import {
} from './savedWorkflowFieldUtils';
import type { FieldComponentProps } from './types';
-const ownedQueryArg = getSavedWorkflowPickerOwnedQueryArg() satisfies Parameters<
- typeof useListWorkflowsInfiniteInfiniteQuery
->[0];
-const sharedQueryArg = getSavedWorkflowPickerSharedQueryArg() satisfies Parameters<
- typeof useListWorkflowsInfiniteInfiniteQuery
->[0];
-
const queryOptions = {
selectFromResult: ({ data, ...rest }) => ({
items: data?.pages.flatMap(({ items }) => items) ?? EMPTY_ARRAY,
@@ -43,6 +36,22 @@ const SavedWorkflowFieldInputComponent = (
const { nodeId, field } = props;
const dispatch = useAppDispatch();
const { t } = useTranslation();
+ const [workflowSearchQuery, setWorkflowSearchQuery] = useState('');
+ const deferredWorkflowSearchQuery = useDeferredValue(workflowSearchQuery);
+ const ownedQueryArg = useMemo(
+ () =>
+ getSavedWorkflowPickerOwnedQueryArg(deferredWorkflowSearchQuery) satisfies Parameters<
+ typeof useListWorkflowsInfiniteInfiniteQuery
+ >[0],
+ [deferredWorkflowSearchQuery]
+ );
+ const sharedQueryArg = useMemo(
+ () =>
+ getSavedWorkflowPickerSharedQueryArg(deferredWorkflowSearchQuery) satisfies Parameters<
+ typeof useListWorkflowsInfiniteInfiniteQuery
+ >[0],
+ [deferredWorkflowSearchQuery]
+ );
const {
items: ownedItems,
isLoading: isOwnedLoading,
@@ -111,6 +120,10 @@ const SavedWorkflowFieldInputComponent = (
fetchNextSharedPage();
}
}, [fetchNextOwnedPage, fetchNextSharedPage, hasNextOwnedPage, hasNextSharedPage, isOwnedFetching, isSharedFetching]);
+ const onInputChange = useCallback((inputValue: string) => {
+ setWorkflowSearchQuery(inputValue);
+ return inputValue;
+ }, []);
const noOptionsMessage = useCallback(() => t('nodes.noMatchingWorkflows'), [t]);
const isLoading = isOwnedLoading || isSharedLoading;
@@ -123,6 +136,7 @@ const SavedWorkflowFieldInputComponent = (
value={value}
options={options}
onChange={onChange}
+ onInputChange={onInputChange}
onMenuScrollToBottom={onMenuScrollToBottom}
placeholder={isLoading ? t('common.loading') : t('controlLayers.workflowIntegration.selectPlaceholder')}
noOptionsMessage={noOptionsMessage}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
index d0abe34baba..3439cbf3747 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.test.ts
@@ -159,15 +159,17 @@ describe('savedWorkflowFieldUtils', () => {
});
it('queries owned/default workflows and shared public workflows separately', () => {
- expect(getSavedWorkflowPickerOwnedQueryArg()).toMatchObject({
+ expect(getSavedWorkflowPickerOwnedQueryArg('landscape')).toMatchObject({
page: 0,
per_page: 50,
+ query: 'landscape',
categories: ['user', 'default'],
is_public: undefined,
});
- expect(getSavedWorkflowPickerSharedQueryArg()).toMatchObject({
+ expect(getSavedWorkflowPickerSharedQueryArg('landscape')).toMatchObject({
page: 0,
per_page: 50,
+ query: 'landscape',
categories: ['user'],
is_public: true,
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index 95476c5939a..f64fe14d11e 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -24,19 +24,20 @@ const baseSavedWorkflowPickerQueryArg = {
per_page: SAVED_WORKFLOW_PICKER_PAGE_SIZE,
order_by: 'name',
direction: 'ASC',
- query: '',
tags: [] as string[],
has_been_opened: undefined,
} as const;
-export const getSavedWorkflowPickerOwnedQueryArg = () => ({
+export const getSavedWorkflowPickerOwnedQueryArg = (query = '') => ({
...baseSavedWorkflowPickerQueryArg,
+ query,
categories: ['user', 'default'] as ('user' | 'default')[],
is_public: undefined,
});
-export const getSavedWorkflowPickerSharedQueryArg = () => ({
+export const getSavedWorkflowPickerSharedQueryArg = (query = '') => ({
...baseSavedWorkflowPickerQueryArg,
+ query,
categories: ['user'] as ('user' | 'default')[],
is_public: true,
});
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
index f643842ec62..7629e3e769d 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.test.ts
@@ -1,5 +1,5 @@
import { deepClone } from 'common/util/deepClone';
-import type { IntegerFieldInputTemplate } from 'features/nodes/types/field';
+import type { IntegerFieldInputTemplate, StringFieldInputTemplate } from 'features/nodes/types/field';
import { buildConnectorNode } from 'features/nodes/util/node/buildConnectorNode';
import { describe, expect, it } from 'vitest';
@@ -28,6 +28,22 @@ const buildDynamicIntegerTemplate = (fieldName: string): IntegerFieldInputTempla
input: 'any' as const,
});
+const buildDynamicStringTemplate = (fieldName: string): StringFieldInputTemplate => ({
+ name: fieldName,
+ title: 'Prompt',
+ required: false,
+ description: 'Prompt text',
+ fieldKind: 'input',
+ input: 'any',
+ ui_hidden: false,
+ default: 'new default',
+ type: {
+ name: 'StringField',
+ cardinality: 'SINGLE',
+ batch: false,
+ },
+});
+
const buildFixedConnectorNode = (id: string) => {
const connectorNode = buildConnectorNode({ x: 0, y: 0 });
return {
@@ -132,6 +148,65 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {
expect(resyncedNode.data.dynamicInputTemplates[fieldName]?.name).toBe(fieldName);
});
+ it('resets an existing dynamic field value when the exposed field type changes', () => {
+ const state = nodesSliceConfig.getInitialState();
+ const node = buildNode(callSavedWorkflowTemplate);
+ state.nodes.push(node);
+
+ const fieldName = 'saved_workflow_input::node-1::a';
+
+ let nextState = nodesSliceConfig.slice.reducer(
+ state,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [
+ {
+ fieldName,
+ fieldTemplate: buildDynamicIntegerTemplate(fieldName),
+ label: 'Left Addend',
+ description: 'The first number',
+ initialValue: 23,
+ },
+ ],
+ edgeIdsToRemove: [],
+ })
+ );
+
+ nextState = nodesSliceConfig.slice.reducer(
+ nextState,
+ fieldIntegerValueChanged({
+ nodeId: node.id,
+ fieldName,
+ value: 99,
+ })
+ );
+
+ nextState = nodesSliceConfig.slice.reducer(
+ nextState,
+ callSavedWorkflowDynamicFieldsChanged({
+ nodeId: node.id,
+ fields: [
+ {
+ fieldName,
+ fieldTemplate: buildDynamicStringTemplate(fieldName),
+ label: 'Prompt',
+ description: 'Prompt text',
+ initialValue: 'new default',
+ },
+ ],
+ edgeIdsToRemove: [],
+ })
+ );
+
+ const resyncedNode = nextState.nodes[0];
+ if (!resyncedNode || resyncedNode.type !== 'invocation') {
+ throw new Error('Expected invocation node');
+ }
+
+ expect(resyncedNode.data.inputs[fieldName]?.value).toBe('new default');
+ expect(resyncedNode.data.dynamicInputTemplates[fieldName]?.type.name).toBe('StringField');
+ });
+
it('removes stale dynamic field templates when the selected workflow fields change', () => {
const state = nodesSliceConfig.getInitialState();
const node = buildNode(callSavedWorkflowTemplate);
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index 46fdd663ce1..9c6a9eea9f0 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -583,9 +583,22 @@ const slice = createSlice({
}
for (const { fieldName, fieldTemplate, label, description, initialValue } of fields) {
+ const existingTemplate = node.data.dynamicInputTemplates[fieldName];
node.data.dynamicInputTemplates[fieldName] = fieldTemplate;
const existing = node.data.inputs[fieldName];
if (existing) {
+ if (
+ existingTemplate?.type.name !== fieldTemplate.type.name ||
+ existingTemplate?.type.cardinality !== fieldTemplate.type.cardinality ||
+ existingTemplate?.type.batch !== fieldTemplate.type.batch
+ ) {
+ const instance = buildFieldInputInstance(fieldName, fieldTemplate);
+ instance.label = label;
+ instance.description = description;
+ instance.value = initialValue;
+ node.data.inputs[fieldName] = instance;
+ continue;
+ }
existing.label = label;
existing.description = description;
continue;
From a3c45076e5d53142bd27cbb9b336c11d023cb954 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 07:28:30 -0500
Subject: [PATCH 086/100] Avoid eager workflow compatibility expansion
---
docs/contributing/call_saved_workflow.md | 5 +-
invokeai/app/api/routers/workflows.py | 1 +
invokeai/app/api/sockets.py | 4 ++
.../session_processor/workflow_call_batch.py | 29 +++++++-
invokeai/app/services/shared/README.md | 5 ++
.../shared/workflow_call_compatibility.py | 2 +
.../test_workflow_call_compatibility.py | 70 +++++++++++++++++++
tests/app/test_workflow_socketio.py | 23 ++++++
8 files changed, 136 insertions(+), 3 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 5a6986148ba..fad016097b8 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -147,6 +147,8 @@ Implemented conversion helper:
- unsupported callees are rejected before any child queue row is created
- Compatibility metadata is now exposed through workflow library API responses:
- workflow list items and workflow detail responses include `call_saved_workflow_compatibility`
+ - workflow list items use structural generator-backed batch checks so list/picker rendering does not enumerate every
+ image in board-backed generators; workflow detail and runtime execution still resolve real generator values
- the saved-workflow picker uses that metadata to disable unsupported workflows before execution
- the picker still allows an already-selected unsupported workflow to render, with an explicit unsupported state and
backend-provided reason message
@@ -302,7 +304,8 @@ The current queue-visible implementation uses the following lifecycle contract:
must receive only the retry item ids for their own roots, while admins can still observe the full retried set
- workflow live-update sockets join workflow event rooms in both authenticated multiuser mode and unauthenticated
single-user mode; the frontend relies on those events to invalidate workflow library data and clear deleted saved
- workflow selections
+ workflow selections; in single-user mode, workflow CRUD events emit only to the admin room to avoid duplicate delivery
+ to sockets that are also joined to `user:system`
- the saved-workflow node picker queries owned/default workflows and public shared workflows separately, merges them by
workflow id, and fetches additional pages as the combobox menu reaches the end
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 1e081444d14..7daf93fb5ce 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -215,6 +215,7 @@ async def list_workflows(
services=ApiDependencies.invoker.services,
user_id=current_user.user_id,
maximum_children=ApiDependencies.invoker.services.configuration.max_queue_size,
+ resolve_generator_items=False,
)
workflows_with_thumbnails.append(
WorkflowRecordListItemWithThumbnailDTO(
diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py
index d16b062110c..c281cc62ab1 100644
--- a/invokeai/app/api/sockets.py
+++ b/invokeai/app/api/sockets.py
@@ -400,6 +400,10 @@ async def _handle_workflow_event(self, event: FastAPIEvent[WorkflowEventBase]) -
event_name, event_data = event
payload = event_data.model_dump(mode="json")
+ if not self._is_multiuser_enabled():
+ await self._sio.emit(event=event_name, data=payload, room="admin")
+ return
+
await self._sio.emit(event=event_name, data=payload, room=f"user:{event_data.user_id}")
await self._sio.emit(event=event_name, data=payload, room="admin")
diff --git a/invokeai/app/services/session_processor/workflow_call_batch.py b/invokeai/app/services/session_processor/workflow_call_batch.py
index d63efca81f4..a37b9d5100b 100644
--- a/invokeai/app/services/session_processor/workflow_call_batch.py
+++ b/invokeai/app/services/session_processor/workflow_call_batch.py
@@ -405,6 +405,20 @@ def _resolve_generator_items(generator_node: Mapping[str, Any], services: Any, u
raise UnsupportedWorkflowNodeError(f"Unsupported generator node type '{node_type}'")
+def _get_generator_placeholder_items(generator_node: Mapping[str, Any]) -> list[Any]:
+ generator_node_data = generator_node["data"]
+ node_type = generator_node_data.get("type")
+ if node_type == "integer_generator":
+ return [0]
+ if node_type == "float_generator":
+ return [0.0]
+ if node_type == "string_generator":
+ return [""]
+ if node_type == "image_generator":
+ return [ImageField(image_name="compatibility-placeholder")]
+ raise UnsupportedWorkflowNodeError(f"Unsupported generator node type '{node_type}'")
+
+
def _get_outgoing_default_edges(
node_id: str, source_handle: str, workflow_edges: Sequence[Mapping[str, Any]]
) -> list[Mapping[str, Any]]:
@@ -515,6 +529,7 @@ def build_batch_child_workflow_session_results(
maximum_children: int,
services: Any = None,
user_id: str | None = None,
+ resolve_generator_items: bool = True,
) -> list[GraphExecutionState]:
mutable_workflow = copy.deepcopy(workflow)
apply_workflow_inputs_to_workflow(mutable_workflow, workflow_inputs)
@@ -546,11 +561,15 @@ def build_batch_child_workflow_session_results(
f"call_saved_workflow generator-backed batch child workflow is missing generator node '{generator_source_id}'"
)
generator_node_type = generator_node["data"].get("type") if _is_invocation_node(generator_node) else None
- if generator_node_type == "image_generator" and services is None:
+ if generator_node_type == "image_generator" and services is None and resolve_generator_items:
raise UnsupportedWorkflowNodeError(
"call_saved_workflow image-generator-backed batch child workflows require runtime services"
)
- batch_items = _resolve_generator_items(generator_node, services, user_id)
+ batch_items = (
+ _resolve_generator_items(generator_node, services, user_id)
+ if resolve_generator_items
+ else _get_generator_placeholder_items(generator_node)
+ )
used_generator_node_ids.add(generator_source_id)
if not batch_items:
raise UnsupportedWorkflowNodeError(
@@ -609,6 +628,7 @@ def build_batch_child_workflow_sessions(
maximum_children: int,
services: Any = None,
user_id: str | None = None,
+ resolve_generator_items: bool = True,
) -> list[GraphExecutionState]:
return [
child_result.session
@@ -620,6 +640,7 @@ def build_batch_child_workflow_sessions(
maximum_children=maximum_children,
services=services,
user_id=user_id,
+ resolve_generator_items=resolve_generator_items,
)
]
@@ -633,6 +654,7 @@ def build_child_workflow_session_results(
maximum_children: int,
services: Any = None,
user_id: str | None = None,
+ resolve_generator_items: bool = True,
) -> list[WorkflowCallChildSessionResult]:
if workflow_contains_supported_batch_nodes(workflow):
return build_batch_child_workflow_session_results(
@@ -643,6 +665,7 @@ def build_child_workflow_session_results(
maximum_children=maximum_children,
services=services,
user_id=user_id,
+ resolve_generator_items=resolve_generator_items,
)
mutable_workflow = copy.deepcopy(workflow)
@@ -661,6 +684,7 @@ def build_child_workflow_sessions(
maximum_children: int,
services: Any = None,
user_id: str | None = None,
+ resolve_generator_items: bool = True,
) -> list[GraphExecutionState]:
return [
child_result.session
@@ -672,5 +696,6 @@ def build_child_workflow_sessions(
maximum_children=maximum_children,
services=services,
user_id=user_id,
+ resolve_generator_items=resolve_generator_items,
)
]
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 3c047676efa..f35502bddf0 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -310,10 +310,15 @@ Current limitation:
queue row is created.
- Workflow library API responses now include compatibility metadata so the frontend can disable unsupported callees
before execution rather than failing only at runtime.
+- Workflow library list compatibility uses structural generator-backed batch validation so list and picker rendering do
+ not enumerate every image in board-backed generators; workflow detail and runtime execution still resolve real
+ generator values.
- Batch-specific compatibility failures, including multiple connected inputs to one batch field, are reported as
`unsupported_batch_input` rather than generic unsupported-node failures.
- The workflow library list also surfaces that metadata as an informational unsupported state; workflows remain
viewable/editable even when they are not currently callable by `call_saved_workflow`.
+- Single-user workflow CRUD socket events emit only to the admin room because every single-user socket already joins
+ that room, avoiding duplicate delivery through both `user:system` and `admin`.
## 8) Error Model (selected)
diff --git a/invokeai/app/services/shared/workflow_call_compatibility.py b/invokeai/app/services/shared/workflow_call_compatibility.py
index df3e46f1a84..a4de59044b2 100644
--- a/invokeai/app/services/shared/workflow_call_compatibility.py
+++ b/invokeai/app/services/shared/workflow_call_compatibility.py
@@ -150,6 +150,7 @@ def get_workflow_call_compatibility(
services: Any,
user_id: str | None,
maximum_children: int,
+ resolve_generator_items: bool = True,
) -> WorkflowCallCompatibility:
workflow_return_count = _count_workflow_return_nodes(workflow)
if workflow_return_count == 0:
@@ -180,6 +181,7 @@ def get_workflow_call_compatibility(
maximum_children=maximum_children,
services=services,
user_id=user_id,
+ resolve_generator_items=resolve_generator_items,
)
except InvalidWorkflowInputError as e:
return WorkflowCallCompatibility(
diff --git a/tests/app/services/test_workflow_call_compatibility.py b/tests/app/services/test_workflow_call_compatibility.py
index c17077a3855..2f47587f548 100644
--- a/tests/app/services/test_workflow_call_compatibility.py
+++ b/tests/app/services/test_workflow_call_compatibility.py
@@ -62,6 +62,23 @@ def _services():
)()
+def _services_that_fail_on_image_enumeration():
+ def fail(*args: Any, **kwargs: Any) -> list[str]:
+ raise AssertionError("image names should not be enumerated for structural compatibility")
+
+ return type(
+ "Services",
+ (),
+ {
+ "board_images": type(
+ "BoardImages",
+ (),
+ {"get_all_board_image_names_for_board": staticmethod(fail)},
+ )(),
+ },
+ )()
+
+
def test_get_workflow_call_compatibility_returns_ok_for_simple_callable_workflow() -> None:
workflow = _workflow_dump(
nodes=[
@@ -224,6 +241,59 @@ def test_get_workflow_call_compatibility_allows_batch_directly_into_workflow_ret
assert compatibility.message is None
+def test_get_workflow_call_compatibility_can_skip_generator_expansion_for_list_views() -> None:
+ workflow = _workflow_dump(
+ nodes=[
+ _invocation_node(
+ "generator",
+ "image_generator",
+ {
+ "generator": {
+ "value": {
+ "type": "image_generator_images_from_board",
+ "board_id": "board-a",
+ "category": "images",
+ }
+ }
+ },
+ ),
+ _invocation_node("batch", "image_batch", {"images": {"value": []}, "batch_group_id": {"value": "None"}}),
+ _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ ],
+ edges=[
+ {
+ "id": "edge-generator-batch",
+ "type": "default",
+ "source": "generator",
+ "sourceHandle": "collection",
+ "target": "batch",
+ "targetHandle": "images",
+ },
+ {
+ "id": "edge-batch-return",
+ "type": "default",
+ "source": "batch",
+ "sourceHandle": "images",
+ "target": "return",
+ "targetHandle": "collection",
+ },
+ ],
+ )
+
+ compatibility = get_workflow_call_compatibility(
+ workflow=workflow,
+ workflow_id="workflow-a",
+ services=_services_that_fail_on_image_enumeration(),
+ user_id="user-1",
+ maximum_children=1000,
+ resolve_generator_items=False,
+ )
+
+ assert compatibility.is_callable is True
+ assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
+ assert compatibility.message is None
+
+
def test_get_workflow_call_compatibility_reports_multiple_batch_inputs_as_unsupported_batch_input() -> None:
workflow = _workflow_dump(
nodes=[
diff --git a/tests/app/test_workflow_socketio.py b/tests/app/test_workflow_socketio.py
index ba9be84fba8..04625fb44ae 100644
--- a/tests/app/test_workflow_socketio.py
+++ b/tests/app/test_workflow_socketio.py
@@ -96,6 +96,29 @@ async def test_private_workflow_event_is_emitted_only_to_owner_and_admin() -> No
assert socketio._sio.emit.await_count == 2
+@pytest.mark.anyio
+async def test_single_user_workflow_event_is_emitted_once_to_admin_room(monkeypatch: pytest.MonkeyPatch) -> None:
+ socketio = SocketIO(FastAPI())
+ socketio._sio.emit = AsyncMock()
+ _patch_single_user_context(monkeypatch)
+
+ event_payload = SimpleNamespace(
+ __event_name__="workflow_created",
+ workflow_id="wf-1",
+ user_id="system",
+ is_public=False,
+ model_dump=lambda mode="json": {"workflow_id": "wf-1", "user_id": "system", "is_public": False},
+ )
+
+ await socketio._handle_workflow_event(("workflow_created", event_payload))
+
+ socketio._sio.emit.assert_awaited_once_with(
+ event="workflow_created",
+ data={"workflow_id": "wf-1", "user_id": "system", "is_public": False},
+ room="admin",
+ )
+
+
@pytest.mark.anyio
async def test_shared_workflow_event_is_emitted_to_shared_room() -> None:
socketio = SocketIO(FastAPI())
From 3a0a7f196f513b510265cfc26601c53cbda8903f Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 07:31:02 -0500
Subject: [PATCH 087/100] Fix workflow call README list nesting
---
invokeai/app/services/shared/README.md | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index f35502bddf0..e08d9513741 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -64,12 +64,12 @@ Runs a sequence of checks:
`saved_workflow_input::{childNodeId}::{childFieldName}` as part of its temporary call-boundary contract.
- Those handles are allowed through graph validation even though they are not static Python model fields on the
invocation class.
- - Runtime later validates them against the selected child workflow's exposed callable interface before applying
- values to the child graph.
- - The editor preserves dynamic caller values only while the exposed field type remains compatible; type drift at the
- same child node/field path resets to the selected workflow's current initial value.
- - Saved-workflow picker search is server-backed so large workflow libraries do not require scrolling every page before
- selecting a workflow by name.
+ - Runtime later validates them against the selected child workflow's exposed callable interface before applying
+ values to the child graph.
+ - The editor preserves dynamic caller values only while the exposed field type remains compatible; type drift at the
+ same child node/field path resets to the selected workflow's current initial value.
+ - Saved-workflow picker search is server-backed so large workflow libraries do not require scrolling every page before
+ selecting a workflow by name.
1. **Iterator / collector structure** Enforce special rules:
From eed386f399bcfa3ba42417c44b10d826be14ad38 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 07:31:27 -0500
Subject: [PATCH 088/100] chore: mdformat
---
invokeai/app/services/shared/README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index e08d9513741..229c9951a4f 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -68,8 +68,8 @@ Runs a sequence of checks:
values to the child graph.
- The editor preserves dynamic caller values only while the exposed field type remains compatible; type drift at the
same child node/field path resets to the selected workflow's current initial value.
- - Saved-workflow picker search is server-backed so large workflow libraries do not require scrolling every page before
- selecting a workflow by name.
+ - Saved-workflow picker search is server-backed so large workflow libraries do not require scrolling every page
+ before selecting a workflow by name.
1. **Iterator / collector structure** Enforce special rules:
From 628e123cf6dd4ebe44476ab43ddaa0da46ab2041 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 08:52:46 -0500
Subject: [PATCH 089/100] Updated documentation
---
docs/contributing/call_saved_workflow.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index fad016097b8..5f7bc644ff3 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -457,6 +457,16 @@ Python validation/runtime code.
Do not infer child outputs from arbitrary terminal nodes. That is too ambiguous and too brittle.
+Current limitation:
+
+- returned values are anonymous collection items, not named return fields
+- the caller can consume the returned collection directly or pass it through built-in collection flow nodes such as
+ `iterate`
+- there is not currently a first-class key/value return contract where the called workflow returns named members and the
+ caller extracts a value by name
+- adding that would require a new explicit return interface and companion caller-side extraction nodes, rather than
+ relying on collection item ordering as an implicit API
+
### 6. Error Propagation
If child execution fails:
From e304ad53d2fb1b85a614670080ba85aa27f568df Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 12:08:53 -0500
Subject: [PATCH 090/100] Removed the anonymous collection from the workflow
return production contract, fixed various bugs.
---
docs/contributing/call_saved_workflow.md | 196 ++++++-
.../app/invocations/call_saved_workflow.py | 2 +-
invokeai/app/invocations/workflow_return.py | 115 +++-
.../workflow_call_runtime.py | 12 +-
invokeai/app/services/shared/README.md | 4 +-
invokeai/app/services/shared/graph.py | 29 +-
.../frontend/web/src/services/api/schema.ts | 177 +++++-
.../invocations/test_call_saved_workflows.py | 95 +++-
.../app/services/test_workflow_call_batch.py | 53 +-
.../test_workflow_call_compatibility.py | 116 ++--
.../services/test_workflow_call_runtime.py | 12 +
.../services/test_workflow_graph_builder.py | 68 ++-
.../app/services/workflow_call_test_utils.py | 515 ++++++++++--------
13 files changed, 1019 insertions(+), 375 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index 5f7bc644ff3..d1399a0e13b 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -36,7 +36,8 @@ Implemented already in the branch:
- A real invocation exists: `call_saved_workflow`.
- A real return node exists: `workflow_return`.
-- `workflow_return` accepts a `list[Any]` collection input and returns that collection through a dedicated output.
+- Named returns exist through `workflow_return_value`, `workflow_return`, and caller-side `workflow_return_get`.
+- `workflow_return` accepts collected key/value return members and emits a named `values: dict[str, Any]` map.
- Only one `workflow_return` node is allowed per workflow, enforced in both frontend validation and Python validation.
- The frontend provides a saved-workflow picker using a reusable `SavedWorkflowField` UI type.
- The node redraws dynamically based on the selected saved workflow's exposed form fields.
@@ -81,7 +82,7 @@ Implemented runtime scaffolding:
- `WorkflowCallQueueLifecycle` handles queue-visible parent/child lifecycle:
- run child queue items
- resume waiting parents after child success
- - complete the parent call node with the child `workflow_return` collection
+ - complete the parent call node with the child `workflow_return` values
- fail suspended parents after child failure and cascade that failure upward through parent call chains
- Child `SessionQueueItem` rows now carry explicit relationship metadata:
- `workflow_call_id`
@@ -111,6 +112,7 @@ Implemented runtime scaffolding:
- Queue lifecycle semantics now exist for workflow-call chains:
- parent queue items are suspended in `waiting` while a child queue row runs
- child success resumes the suspended parent and completes the parent call node with the child `workflow_return`
+ values
- child failure fails the suspended parent and cascades upward through any waiting parent chain
- canceling a parent cancels its descendant child chain
- canceling a child cancels the waiting parent chain upward
@@ -337,10 +339,11 @@ Current semantics:
- grouped batch nodes zip by `batch_group_id`
- the workflow call creates one child queue row per expanded batch session
- supported generator value shapes are resolved into concrete batch items before queue batch expansion
-- batch outputs may feed `workflow_return.collection` directly; each expanded child receives a singleton collection, and
- the parent still aggregates all returned child collections
+- batch outputs may feed a named `workflow_return_value.value` directly; each expanded child returns one value for that
+ key
- parent resume waits for all child rows tied to that workflow call
-- parent return aggregation appends each child `workflow_return.collection` into one parent collection
+- parent return aggregation produces `values: dict[str, list[Any]]`, where each key maps to one value per child row
+- all child rows in one batch call must return the same key set; mismatched keys fail the parent call clearly
- if any child row fails, remaining sibling child rows are canceled and the parent call fails
- generator-backed image batches must respect board access:
- the caller may expand images from a board they own
@@ -381,9 +384,9 @@ Plain-English summary:
1. Each child queue row keeps the substituted batch `field_values`, matching ordinary batch queue rows.
1. Those child queue rows run independently.
1. The parent does not resume until all child queue rows for that call have finished.
-1. Each child execution produces its own `workflow_return.collection`.
-1. The parent aggregates those returned collections into one combined collection.
-1. The `call_saved_workflow` node completes with that combined collection, and the parent workflow continues.
+1. Each child execution produces its own named `workflow_return.values` map.
+1. The parent aggregates those maps into `values: dict[str, list[Any]]`.
+1. The `call_saved_workflow` node completes with that named values map, and the parent workflow continues.
Expansion rules:
@@ -413,14 +416,18 @@ Waiting and resume:
Return aggregation:
-- each child queue row returns its own `workflow_return.collection`
-- the parent call node output is the aggregate of those child collections, in child-completion order
-- this is different from returning exactly one child collection unchanged
+- each child queue row returns its own named `workflow_return.values`
+- for batched calls, the parent call node output is `values: dict[str, list[Any]]`
+- all child rows in one batched call must return the same key set so each returned list is row-aligned
+- if one key should contain multiple images for a non-batch child, the child must collect those images into a single
+ list value before returning that key
Sibling failure behavior:
- if one child queue row in a batched workflow call fails, remaining sibling child rows for that same workflow call are
canceled
+- if parent return aggregation rejects a completed child row, remaining sibling child rows for that same workflow call
+ are canceled
- after sibling cancelation, the parent call fails
- if that parent is itself a child of another workflow call, failure continues upward through the ancestor chain
@@ -446,26 +453,36 @@ Return values should be explicit.
Recommended model:
- introduce a workflow return node analogous in concept to Canvas Output
-- the child workflow declares what values it returns through that explicit node
-- the return node accepts a `list[Any]` collection input
+- the child workflow declares named return values through explicit key/value return members
+- each return member has a stable string key and a connected value
- when the workflow is run independently, the return node has no caller-visible effect
-- when the workflow is run via `call_saved_workflow`, that collection becomes the return value of the call
-- `call_saved_workflow` should expose that collection as its return value in the first runtime version
+- when the workflow is run via `call_saved_workflow`, the named return map becomes the return value of the call
+- `call_saved_workflow` exposes that named return map to the parent workflow
Only one workflow return node may exist per workflow. That rule should be enforced in both the frontend editor and in
Python validation/runtime code.
Do not infer child outputs from arbitrary terminal nodes. That is too ambiguous and too brittle.
-Current limitation:
+Named return contract:
+
+- the called workflow builds return members with a dedicated key/value node
+- `workflow_return` accepts a collection of those return members
+- non-batch execution rejects duplicate return keys
+- if a non-batch workflow needs to return multiple images under one key, the child workflow should collect those images
+ into one list value and return that list under the key
+- the caller extracts a named return value with a companion caller-side extraction node
+- missing keys should fail clearly unless the extraction node explicitly supports and receives a default value
+
+Batch return aggregation:
-- returned values are anonymous collection items, not named return fields
-- the caller can consume the returned collection directly or pass it through built-in collection flow nodes such as
- `iterate`
-- there is not currently a first-class key/value return contract where the called workflow returns named members and the
- caller extracts a value by name
-- adding that would require a new explicit return interface and companion caller-side extraction nodes, rather than
- relying on collection item ordering as an implicit API
+- when a called workflow expands into multiple child queue rows, each child row produces its own named return map
+- the parent aggregates those child maps as `dict[str, list[Any]]`
+- each key maps to the list of values returned by completed child rows for that key
+- child rows are still aggregated in child-completion order unless a later contract explicitly requires stable input
+ order
+- duplicate keys within a single child return map are still invalid; repeated keys across batch children are the normal
+ aggregation path
### 6. Error Propagation
@@ -554,8 +571,8 @@ A dedicated runtime helper for this node type should be introduced. Responsibili
A dedicated child-workflow return node should be introduced. Responsibilities:
- define the return interface of the called workflow
-- accept a `list[Any]` collection input representing the workflow result
-- provide that collection back to the parent call site when invoked through `call_saved_workflow`
+- accept collected named key/value return members representing the workflow result
+- provide that named values map back to the parent call site when invoked through `call_saved_workflow`
- remain inert from a caller perspective when the workflow is run independently
- guarantee that only one such node exists per workflow
- behave as a normal node in the editor, with singularity enforced by both frontend and Python validation/runtime code
@@ -578,14 +595,131 @@ frontend state.
The intended runtime flow is:
-1. The child workflow computes the `workflow_return` node's collection input like any other node input.
-1. When the child reaches `workflow_return`, runtime captures the resolved collection value as the child workflow
+1. The child workflow computes named return members like ordinary node outputs.
+1. The child workflow collects those members into the `workflow_return` node.
+1. When the child reaches `workflow_return`, runtime captures the resolved named return map as the child workflow
result.
1. The child workflow result is stored in child execution state.
1. That result is handed back to the suspended parent call frame.
-1. The parent `call_saved_workflow` node is completed with that returned collection.
+1. The parent `call_saved_workflow` node is completed with that returned named value map.
1. The parent graph resumes.
+## Named Return Implementation Plan
+
+This is the next planned feature slice. Development should proceed test-first and keep documentation updated as each
+stage lands.
+
+### Stage 1: Backend Return Contract
+
+Status: implemented in backend invocation tests.
+
+Goal:
+
+- establish the named return data model and invocation primitives
+
+Contract:
+
+- `WorkflowReturnValueField` stores one `key: str` and one `value: Any`
+- `workflow_return_value` creates a single `WorkflowReturnValueField` from a key and connected value
+- `workflow_return` accepts a collection of `WorkflowReturnValueField` members
+- `WorkflowReturnOutput` exposes `values: dict[str, Any]`
+- duplicate keys in one non-batch `workflow_return` execution are invalid and must fail clearly
+
+Tests first:
+
+- `workflow_return_value` emits the requested key/value pair
+- `workflow_return` emits a named value map from one or more return members
+- duplicate keys in one `workflow_return` execution are rejected
+- empty returns are valid only if that remains an intentional callable-workflow contract
+
+### Stage 2: Caller-Side Extraction Primitive
+
+Status: implemented in backend invocation tests.
+
+Goal:
+
+- let the calling workflow extract a named return value without relying on collection position
+
+Contract:
+
+- `workflow_return_get` accepts the named return map and a key
+- `workflow_return_get` outputs the selected value as `Any`
+- missing keys fail clearly unless a later version intentionally adds default-value support
+
+Tests first:
+
+- extracting an existing key returns the stored value
+- extracting a missing key fails with a useful message
+- extracted `Any` values can feed typed downstream nodes through the existing connection compatibility rules
+
+### Stage 3: Runtime Propagation
+
+Status: implemented in backend runtime tests.
+
+Goal:
+
+- carry named return maps through queue-visible child execution and parent resume
+
+Contract:
+
+- non-batch child execution returns `values: dict[str, Any]`
+- `call_saved_workflow` exposes that map on its output
+- failed child execution behavior is unchanged
+- cancel/retry lifecycle behavior is unchanged
+
+Tests first:
+
+- a called workflow returning `{image: image_value}` completes the parent `call_saved_workflow` output with that key
+- a caller-side extraction node can consume that output after parent resume
+- missing or invalid `workflow_return` nodes still fail with the existing clear errors
+
+### Stage 4: Batch Return Aggregation
+
+Status: implemented in backend runtime tests.
+
+Goal:
+
+- define named returns for child workflows that expand into multiple queue rows
+
+Contract:
+
+- each child queue row produces one named return map
+- the parent aggregates child maps as `dict[str, list[Any]]`
+- each key maps to values returned by completed child rows for that key
+- all child rows in one batch call must return the same key set
+- repeated keys across child rows are expected
+- duplicate keys within one child row remain invalid
+- if a non-batch workflow wants multiple images under one key, it must collect those images into a single list value
+ before returning that key
+
+Tests first:
+
+- a batched child returning `{image: image_value}` from each child row produces `{image: [image_1, image_2, ...]}`
+- sibling failure still cancels remaining siblings and fails the parent
+- duplicate keys inside one child row are rejected rather than silently aggregated
+
+### Stage 5: Frontend Schema, UI, And Docs
+
+Status: partially implemented. Schema/type generation includes the backend nodes and fields; editor-specific UX cleanup
+is still pending.
+
+Goal:
+
+- make named returns usable and visible in the editor
+
+Contract:
+
+- generated schema/types include the new return field, return-value node, and extraction node
+- visible UI strings are localized through `en.json`
+- `call_saved_workflow` exposes the named return map output
+- users can wire that output to `workflow_return_get`
+
+Tests first:
+
+- frontend connection/type tests cover return-value collection wiring
+- frontend connection/type tests cover `call_saved_workflow.values -> workflow_return_get.values`
+- docs describe how a called workflow creates named returns and how a caller extracts them
+
## Frontend Responsibilities In The Long-Term Design
The frontend remains responsible for editor-time behavior:
@@ -623,13 +757,15 @@ Already covered:
- literal and connected dynamic call arguments are applied to the child graph at runtime
- non-exposed dynamic call arguments are rejected at runtime
- child `workflow_return` output is captured and becomes the parent `call_saved_workflow` output
+- named `workflow_return` values can be constructed, propagated to the parent, extracted by key, and batch-aggregated as
+ `dict[str, list[Any]]`
- child workflows without a `workflow_return` node fail cleanly when called
- child execution events now include stable workflow-call relationship metadata on the child `SessionQueueItem`
- parent-child resume and failure propagation through queue-visible child rows
- nested runtime execution with bounded stack depth
- direct and generator-backed batch-special child workflows through queue child-row expansion
-- compatibility metadata for required exposed inputs, missing/multiple returns, supported batch-to-return collection
- shapes, and unsupported batch input wiring
+- compatibility metadata for required exposed inputs, missing/multiple returns, supported named batch-return shapes, and
+ unsupported batch input wiring
Still needed in later increments:
diff --git a/invokeai/app/invocations/call_saved_workflow.py b/invokeai/app/invocations/call_saved_workflow.py
index 7b01d8308dc..36f03021b1c 100644
--- a/invokeai/app/invocations/call_saved_workflow.py
+++ b/invokeai/app/invocations/call_saved_workflow.py
@@ -72,4 +72,4 @@ def validate_selected_workflow(self, context: InvocationContext):
def invoke(self, context: InvocationContext) -> WorkflowReturnOutput:
self.validate_selected_workflow(context)
- return WorkflowReturnOutput(collection=[])
+ return WorkflowReturnOutput(values={})
diff --git a/invokeai/app/invocations/workflow_return.py b/invokeai/app/invocations/workflow_return.py
index 66f383e598b..9e0dbd69d59 100644
--- a/invokeai/app/invocations/workflow_return.py
+++ b/invokeai/app/invocations/workflow_return.py
@@ -1,5 +1,7 @@
from typing import Any
+from pydantic import BaseModel, Field
+
from invokeai.app.invocations.baseinvocation import (
BaseInvocation,
BaseInvocationOutput,
@@ -7,21 +9,67 @@
invocation,
invocation_output,
)
-from invokeai.app.invocations.fields import InputField, OutputField, UIType
+from invokeai.app.invocations.fields import Input, InputField, OutputField, UIType
from invokeai.app.services.shared.invocation_context import InvocationContext
@invocation_output("workflow_return_output")
class WorkflowReturnOutput(BaseInvocationOutput):
- """The explicit collection returned from a callable workflow."""
+ """The explicit named values returned from a callable workflow."""
- collection: list[Any] = OutputField(
- description="The workflow return collection",
- title="Collection",
- ui_type=UIType._Collection,
+ values: dict[str, Any] = OutputField(
+ default={},
+ description="The workflow return values, keyed by return name.",
+ title="Values",
+ ui_type=UIType.Any,
+ )
+
+
+class WorkflowReturnValueField(BaseModel):
+ """One named workflow return value."""
+
+ key: str = Field(description="The workflow return key.")
+ value: Any = Field(description="The workflow return value.")
+
+
+@invocation_output("workflow_return_value_output")
+class WorkflowReturnValueOutput(BaseInvocationOutput):
+ """A named workflow return value."""
+
+ value: WorkflowReturnValueField = OutputField(
+ description="The named workflow return value.",
+ title="Return Value",
+ ui_type=UIType._CollectionItem,
)
+@invocation(
+ "workflow_return_value",
+ title="Workflow Return Value",
+ tags=["workflow", "return", "output"],
+ category="workflow",
+ version="1.0.0",
+ classification=Classification.Beta,
+ use_cache=False,
+)
+class WorkflowReturnValueInvocation(BaseInvocation):
+ """Creates one named value for a callable workflow return."""
+
+ key: str = InputField(default="", description="The return key.", title="Key")
+ value: Any = InputField(
+ default=None,
+ description="The value returned under this key.",
+ title="Value",
+ ui_type=UIType.Any,
+ )
+
+ def invoke(self, context: InvocationContext) -> WorkflowReturnValueOutput:
+ key = self.key.strip()
+ if not key:
+ raise ValueError("Workflow return key must not be empty.")
+ return WorkflowReturnValueOutput(value=WorkflowReturnValueField(key=key, value=self.value))
+
+
@invocation(
"workflow_return",
title="Workflow Return",
@@ -32,14 +80,59 @@ class WorkflowReturnOutput(BaseInvocationOutput):
use_cache=False,
)
class WorkflowReturnInvocation(BaseInvocation):
- """Defines the explicit collection result returned by a callable workflow."""
+ """Defines the explicit named result returned by a callable workflow."""
- collection: list[Any] = InputField(
+ values: list[WorkflowReturnValueField] = InputField(
default=[],
- description="The collection returned to a calling workflow.",
- title="Collection",
+ description="The named values returned to a calling workflow.",
+ title="Values",
ui_type=UIType._Collection,
)
def invoke(self, context: InvocationContext) -> WorkflowReturnOutput:
- return WorkflowReturnOutput(collection=self.collection)
+ named_values: dict[str, Any] = {}
+ for value in self.values:
+ key = value.key.strip()
+ if not key:
+ raise ValueError("Workflow return key must not be empty.")
+ if key in named_values:
+ raise ValueError(f"Duplicate workflow return key '{key}'.")
+ named_values[key] = value.value
+ return WorkflowReturnOutput(values=named_values)
+
+
+@invocation_output("workflow_return_get_output")
+class WorkflowReturnGetOutput(BaseInvocationOutput):
+ """A value extracted from named workflow return values."""
+
+ value: Any = OutputField(description="The extracted workflow return value.", title="Value", ui_type=UIType.Any)
+
+
+@invocation(
+ "workflow_return_get",
+ title="Get Workflow Return Value",
+ tags=["workflow", "return", "input"],
+ category="workflow",
+ version="1.0.0",
+ classification=Classification.Beta,
+ use_cache=False,
+)
+class WorkflowReturnGetInvocation(BaseInvocation):
+ """Extracts one named value from a callable workflow return."""
+
+ values: dict[str, Any] = InputField(
+ default={},
+ description="The named workflow return values.",
+ title="Values",
+ ui_type=UIType.Any,
+ input=Input.Connection,
+ )
+ key: str = InputField(default="", description="The return key to extract.", title="Key")
+
+ def invoke(self, context: InvocationContext) -> WorkflowReturnGetOutput:
+ key = self.key.strip()
+ if not key:
+ raise ValueError("Workflow return key must not be empty.")
+ if key not in self.values:
+ raise ValueError(f"Workflow return key '{key}' was not found.")
+ return WorkflowReturnGetOutput(value=self.values[key])
diff --git a/invokeai/app/services/session_processor/workflow_call_runtime.py b/invokeai/app/services/session_processor/workflow_call_runtime.py
index a9b442a6907..f7ff3a729de 100644
--- a/invokeai/app/services/session_processor/workflow_call_runtime.py
+++ b/invokeai/app/services/session_processor/workflow_call_runtime.py
@@ -185,12 +185,18 @@ def _resume_parent_from_completed_child(self, child_queue_item: SessionQueueItem
return
try:
output = self.get_child_workflow_return_output(child_queue_item.session)
- should_resume_parent, aggregated_collection = (
+ should_resume_parent, aggregated_values = (
parent_queue_item.session.record_waiting_workflow_call_child_completion(
- child_queue_item.item_id, output.collection
+ child_queue_item.item_id, output.values
)
)
except Exception as e:
+ workflow_call_execution = parent_queue_item.session.waiting_workflow_call_execution
+ if workflow_call_execution is not None:
+ self._session_runner._services.session_queue.cancel_workflow_call_children(
+ workflow_call_execution.id,
+ exclude_item_ids={child_queue_item.item_id},
+ )
self.fail_waiting_workflow_call(parent_queue_item, str(e))
parent_queue_item = self._session_runner._services.session_queue.get_queue_item(parent_queue_item.item_id)
if getattr(parent_queue_item, "parent_item_id", None) is not None:
@@ -204,7 +210,7 @@ def _resume_parent_from_completed_child(self, child_queue_item: SessionQueueItem
parent_queue_item.session.waiting_workflow_call_child_session = child_queue_item.session
waiting_invocation = self.get_waiting_workflow_call_invocation(parent_queue_item)
parent_queue_item.session.end_waiting_on_workflow_call(status="completed")
- parent_output = WorkflowReturnOutput(collection=aggregated_collection)
+ parent_output = WorkflowReturnOutput(values=aggregated_values)
parent_queue_item.session.complete(waiting_invocation.id, parent_output)
self._session_runner._on_after_run_node(waiting_invocation, parent_queue_item, parent_output)
parent_queue_item = self._session_runner._services.session_queue.set_queue_item_session(
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 229c9951a4f..41b99088a96 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -302,8 +302,8 @@ Current limitation:
generalized dependent-queue scheduler.
- Called workflows currently require exactly one valid `workflow_return` node to be callable at all.
- Direct batch-special child workflows are now supported by expanding them into multiple child queue rows.
-- Batch outputs may feed `workflow_return.collection` directly; each expanded child receives a singleton collection and
- parent resume aggregates those child return collections.
+- Batch outputs may feed a named `workflow_return_value.value` directly. Parent resume aggregates named return maps as
+ `values: dict[str, list[Any]]`, and all rows in one batch call must return the same key set.
- Generator-backed batch child workflows are now supported when the batch node is fed directly by a supported integer,
float, string, or image generator.
- Connected batch child inputs produced by ordinary non-generator upstream nodes are still rejected before any child
diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py
index ed4c4c3c571..41554a15af1 100644
--- a/invokeai/app/services/shared/graph.py
+++ b/invokeai/app/services/shared/graph.py
@@ -100,9 +100,9 @@ class WorkflowCallExecution(BaseModel):
default_factory=list,
description="The child queue item ids whose workflow_return outputs have been aggregated.",
)
- aggregated_collection: list[Any] = Field(
- default_factory=list,
- description="The aggregated workflow_return collection accumulated from child executions.",
+ aggregated_values: dict[str, list[Any]] = Field(
+ default_factory=dict,
+ description="The aggregated workflow_return values accumulated from child executions.",
)
@@ -2149,18 +2149,33 @@ def attach_waiting_workflow_call_child_sessions(self, child_sessions: list["Grap
)
def record_waiting_workflow_call_child_completion(
- self, child_item_id: int, output_collection: list[Any]
- ) -> tuple[bool, list[Any]]:
+ self, child_item_id: int, output_values: dict[str, Any]
+ ) -> tuple[bool, dict[str, Any]]:
if self.waiting_workflow_call_execution is None:
raise ValueError("Execution state is not waiting on a workflow call.")
if child_item_id not in self.waiting_workflow_call_execution.completed_child_item_ids:
+ if (
+ self.waiting_workflow_call_execution.expected_child_count > 1
+ and self.waiting_workflow_call_execution.completed_child_item_ids
+ and set(output_values.keys()) != set(self.waiting_workflow_call_execution.aggregated_values.keys())
+ ):
+ raise ValueError("Batched child workflows returned different workflow return keys.")
self.waiting_workflow_call_execution.completed_child_item_ids.append(child_item_id)
- self.waiting_workflow_call_execution.aggregated_collection.extend(output_collection)
+ for key, value in output_values.items():
+ self.waiting_workflow_call_execution.aggregated_values.setdefault(key, []).append(value)
is_complete = (
len(self.waiting_workflow_call_execution.completed_child_item_ids)
>= self.waiting_workflow_call_execution.expected_child_count
)
- return is_complete, list(self.waiting_workflow_call_execution.aggregated_collection)
+ if self.waiting_workflow_call_execution.expected_child_count == 1:
+ return (
+ is_complete,
+ {key: values[0] for key, values in self.waiting_workflow_call_execution.aggregated_values.items()},
+ )
+ return (
+ is_complete,
+ {key: list(values) for key, values in self.waiting_workflow_call_execution.aggregated_values.items()},
+ )
def end_waiting_on_workflow_call(
self,
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 22219ccc8d3..122cf4fcf70 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -12187,7 +12187,7 @@ export type components = {
* @description The nodes in this graph
*/
nodes?: {
- [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
};
/**
* Edges
@@ -12224,7 +12224,7 @@ export type components = {
* @description The results of node executions
*/
results: {
- [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
+ [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
};
/**
* Errors
@@ -15603,7 +15603,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -15613,7 +15613,7 @@ export type components = {
* Result
* @description The result of the invocation
*/
- result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
+ result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
};
/**
* InvocationErrorEvent
@@ -15667,7 +15667,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -15932,6 +15932,8 @@ export type components = {
unsharp_mask: components["schemas"]["ImageOutput"];
vae_loader: components["schemas"]["VAEOutput"];
workflow_return: components["schemas"]["WorkflowReturnOutput"];
+ workflow_return_get: components["schemas"]["WorkflowReturnGetOutput"];
+ workflow_return_value: components["schemas"]["WorkflowReturnValueOutput"];
z_image_control: components["schemas"]["ZImageControlOutput"];
z_image_denoise: components["schemas"]["LatentsOutput"];
z_image_denoise_meta: components["schemas"]["LatentsMetaOutput"];
@@ -15995,7 +15997,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -16070,7 +16072,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -31323,10 +31325,12 @@ export type components = {
*/
completed_child_item_ids?: number[];
/**
- * Aggregated Collection
- * @description The aggregated workflow_return collection accumulated from child executions.
+ * Aggregated Values
+ * @description The aggregated workflow_return values accumulated from child executions.
*/
- aggregated_collection?: unknown[];
+ aggregated_values?: {
+ [key: string]: unknown[];
+ };
};
/**
* WorkflowCallFrame
@@ -31607,9 +31611,69 @@ export type components = {
/** @description Whether this workflow is currently callable by call_saved_workflow. */
call_saved_workflow_compatibility?: components["schemas"]["WorkflowCallCompatibility"] | null;
};
+ /**
+ * Get Workflow Return Value
+ * @description Extracts one named value from a callable workflow return.
+ */
+ WorkflowReturnGetInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default false
+ */
+ use_cache?: boolean;
+ /**
+ * Values
+ * @description The named workflow return values.
+ * @default {}
+ */
+ values?: {
+ [key: string]: unknown;
+ };
+ /**
+ * Key
+ * @description The return key to extract.
+ * @default
+ */
+ key?: string;
+ /**
+ * type
+ * @default workflow_return_get
+ * @constant
+ */
+ type: "workflow_return_get";
+ };
+ /**
+ * WorkflowReturnGetOutput
+ * @description A value extracted from named workflow return values.
+ */
+ WorkflowReturnGetOutput: {
+ /**
+ * Value
+ * @description The extracted workflow return value.
+ */
+ value: unknown;
+ /**
+ * type
+ * @default workflow_return_get_output
+ * @constant
+ */
+ type: "workflow_return_get_output";
+ };
/**
* Workflow Return
- * @description Defines the explicit collection result returned by a callable workflow.
+ * @description Defines the explicit named result returned by a callable workflow.
*/
WorkflowReturnInvocation: {
/**
@@ -31630,11 +31694,11 @@ export type components = {
*/
use_cache?: boolean;
/**
- * Collection
- * @description The collection returned to a calling workflow.
+ * Values
+ * @description The named values returned to a calling workflow.
* @default []
*/
- collection?: unknown[];
+ values?: components["schemas"]["WorkflowReturnValueField"][];
/**
* type
* @default workflow_return
@@ -31644,14 +31708,17 @@ export type components = {
};
/**
* WorkflowReturnOutput
- * @description The explicit collection returned from a callable workflow.
+ * @description The explicit named values returned from a callable workflow.
*/
WorkflowReturnOutput: {
/**
- * Collection
- * @description The workflow return collection
+ * Values
+ * @description The workflow return values, keyed by return name.
+ * @default {}
*/
- collection: unknown[];
+ values: {
+ [key: string]: unknown;
+ };
/**
* type
* @default workflow_return_output
@@ -31659,6 +31726,80 @@ export type components = {
*/
type: "workflow_return_output";
};
+ /**
+ * WorkflowReturnValueField
+ * @description One named workflow return value.
+ */
+ WorkflowReturnValueField: {
+ /**
+ * Key
+ * @description The workflow return key.
+ */
+ key: string;
+ /**
+ * Value
+ * @description The workflow return value.
+ */
+ value: unknown;
+ };
+ /**
+ * Workflow Return Value
+ * @description Creates one named value for a callable workflow return.
+ */
+ WorkflowReturnValueInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default false
+ */
+ use_cache?: boolean;
+ /**
+ * Key
+ * @description The return key.
+ * @default
+ */
+ key?: string;
+ /**
+ * Value
+ * @description The value returned under this key.
+ * @default null
+ */
+ value?: unknown | null;
+ /**
+ * type
+ * @default workflow_return_value
+ * @constant
+ */
+ type: "workflow_return_value";
+ };
+ /**
+ * WorkflowReturnValueOutput
+ * @description A named workflow return value.
+ */
+ WorkflowReturnValueOutput: {
+ /**
+ * Return Value
+ * @description The named workflow return value.
+ */
+ value: components["schemas"]["WorkflowReturnValueField"];
+ /**
+ * type
+ * @default workflow_return_value_output
+ * @constant
+ */
+ type: "workflow_return_value_output";
+ };
/**
* WorkflowUpdatedEvent
* @description Event model for workflow_updated
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index 32e1bcac541..e00f91f3d77 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -103,7 +103,7 @@ def test_call_saved_workflow_invocation_contract():
output = invocation.invoke(build_context())
assert isinstance(output, WorkflowReturnOutput)
- assert output.collection == []
+ assert output.values == {}
def test_call_saved_workflow_invocation_raises_when_workflow_id_is_empty():
@@ -164,7 +164,7 @@ def test_call_saved_workflow_invocation_allows_shared_workflow_for_non_owner():
)
)
- assert output.collection == []
+ assert output.values == {}
def test_call_saved_workflow_invocation_allows_default_workflow_for_non_owner():
@@ -186,7 +186,7 @@ def test_call_saved_workflow_invocation_allows_default_workflow_for_non_owner():
)
)
- assert output.collection == []
+ assert output.values == {}
def test_call_saved_workflow_invocation_allows_admin_to_access_private_workflow():
@@ -208,7 +208,7 @@ def test_call_saved_workflow_invocation_allows_admin_to_access_private_workflow(
)
)
- assert output.collection == []
+ assert output.values == {}
def test_call_saved_workflow_invocation_raises_when_private_workflow_user_record_is_missing():
@@ -247,26 +247,97 @@ def test_call_saved_workflow_invocation_schema_declares_saved_workflow_ui_type()
def test_workflow_return_invocation_contract():
- from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnOutput
+ from invokeai.app.invocations.workflow_return import (
+ WorkflowReturnInvocation,
+ WorkflowReturnOutput,
+ WorkflowReturnValueField,
+ )
- invocation = WorkflowReturnInvocation(id="return-node", collection=["a", 1, {"x": True}])
+ invocation = WorkflowReturnInvocation(
+ id="return-node",
+ values=[
+ WorkflowReturnValueField(key="prompt", value="a"),
+ WorkflowReturnValueField(key="count", value=1),
+ WorkflowReturnValueField(key="metadata", value={"x": True}),
+ ],
+ )
assert invocation.get_type() == "workflow_return"
output = invocation.invoke(build_context())
assert isinstance(output, WorkflowReturnOutput)
- assert output.collection == ["a", 1, {"x": True}]
+ assert output.values == {"prompt": "a", "count": 1, "metadata": {"x": True}}
+ assert not hasattr(output, "collection")
+
+
+def test_workflow_return_value_invocation_contract():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnValueField, WorkflowReturnValueInvocation
+
+ invocation = WorkflowReturnValueInvocation(id="return-value-node", key="image", value={"image_name": "image-a"})
+
+ output = invocation.invoke(build_context())
+
+ assert output.value == WorkflowReturnValueField(key="image", value={"image_name": "image-a"})
-def test_workflow_return_invocation_schema_declares_collection_ui_type():
- from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation
+def test_workflow_return_invocation_rejects_duplicate_keys():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
+
+ invocation = WorkflowReturnInvocation(
+ id="return-node",
+ values=[
+ WorkflowReturnValueField(key="image", value="image-a"),
+ WorkflowReturnValueField(key="image", value="image-b"),
+ ],
+ )
+
+ with pytest.raises(ValueError, match="Duplicate workflow return key 'image'"):
+ invocation.invoke(build_context())
+
+
+def test_workflow_return_get_invocation_contract():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
+
+ invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key="image")
+
+ output = invocation.invoke(build_context())
+
+ assert output.value == "image-a"
+
+
+def test_workflow_return_get_invocation_rejects_missing_key():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
+
+ invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key="mask")
+
+ with pytest.raises(ValueError, match="Workflow return key 'mask' was not found"):
+ invocation.invoke(build_context())
+
+
+def test_workflow_return_get_invocation_rejects_empty_key():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
+
+ invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key=" ")
+
+ with pytest.raises(ValueError, match="Workflow return key must not be empty"):
+ invocation.invoke(build_context())
+
+
+def test_workflow_return_invocation_schema_declares_named_values_contract():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation, WorkflowReturnInvocation
schema = WorkflowReturnInvocation.model_json_schema()
- collection = schema["properties"]["collection"]
+ assert "collection" not in schema["properties"]
+ values = schema["properties"]["values"]
+
+ assert values["input"] == "any"
+ assert values["ui_type"] == "CollectionField"
- assert collection["input"] == "any"
- assert collection["ui_type"] == "CollectionField"
+ get_schema = WorkflowReturnGetInvocation.model_json_schema()
+ get_values = get_schema["properties"]["values"]
+ assert get_values["input"] == "connection"
+ assert get_values["ui_type"] == "AnyField"
def test_workflow_without_id_validator_rejects_duplicate_workflow_return_nodes():
diff --git a/tests/app/services/test_workflow_call_batch.py b/tests/app/services/test_workflow_call_batch.py
index 61fa35c17d4..8270bb4711d 100644
--- a/tests/app/services/test_workflow_call_batch.py
+++ b/tests/app/services/test_workflow_call_batch.py
@@ -37,6 +37,55 @@ def _workflow_dump(
edges: list[dict[str, Any]],
exposed_fields: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
+ nodes = list(nodes)
+ edges = list(edges)
+ for node in list(nodes):
+ data = node.get("data", {})
+ if data.get("type") != "workflow_return":
+ continue
+ inputs = data.get("inputs", {})
+ if "collection" not in inputs:
+ continue
+
+ return_node_id = data["id"]
+ value_node_id = f"{return_node_id}-value"
+ collect_node_id = f"{return_node_id}-collect"
+ data["inputs"] = {"values": {"value": []}}
+ nodes.extend(
+ [
+ _invocation_node(
+ value_node_id,
+ "workflow_return_value",
+ {"key": {"value": "result"}, "value": {"value": None}},
+ ),
+ _invocation_node(collect_node_id, "collect", {"collection": {"value": []}}),
+ ]
+ )
+ for edge in edges:
+ if edge.get("target") == return_node_id and edge.get("targetHandle") == "collection":
+ edge["target"] = value_node_id
+ edge["targetHandle"] = "value"
+ edges.extend(
+ [
+ {
+ "id": f"edge-{value_node_id}-collect",
+ "type": "default",
+ "source": value_node_id,
+ "sourceHandle": "value",
+ "target": collect_node_id,
+ "targetHandle": "item",
+ },
+ {
+ "id": f"edge-{collect_node_id}-return",
+ "type": "default",
+ "source": collect_node_id,
+ "sourceHandle": "collection",
+ "target": return_node_id,
+ "targetHandle": "values",
+ },
+ ]
+ )
+
return {
"name": "Child Workflow",
"author": "Tester",
@@ -156,7 +205,7 @@ def test_build_child_workflow_session_results_preserves_batch_field_values() ->
] == [[("target", "value", 2)], [("target", "value", 4)]]
-def test_build_child_workflow_sessions_expands_direct_integer_batch_into_collection_input() -> None:
+def test_build_child_workflow_sessions_expands_direct_integer_batch_into_named_return_value() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node(
@@ -187,7 +236,7 @@ def test_build_child_workflow_sessions_expands_direct_integer_batch_into_collect
)
assert len(child_sessions) == 3
- assert [child_session.graph.nodes["return"].collection for child_session in child_sessions] == [[2], [4], [6]]
+ assert [child_session.graph.nodes["return-value"].value for child_session in child_sessions] == [2, 4, 6]
def test_build_child_workflow_sessions_rejects_inaccessible_image_generator_board() -> None:
diff --git a/tests/app/services/test_workflow_call_compatibility.py b/tests/app/services/test_workflow_call_compatibility.py
index 2f47587f548..1b32d67c25a 100644
--- a/tests/app/services/test_workflow_call_compatibility.py
+++ b/tests/app/services/test_workflow_call_compatibility.py
@@ -44,6 +44,45 @@ def _workflow_dump(*, nodes: list[dict[str, Any]], edges: list[dict[str, Any]])
}
+def _return_nodes() -> list[dict[str, Any]]:
+ return [
+ _invocation_node(
+ "return-value", "workflow_return_value", {"key": {"value": "result"}, "value": {"value": None}}
+ ),
+ _invocation_node("return-collect", "collect", {"collection": {"value": []}}),
+ _invocation_node("return", "workflow_return", {"values": {"value": []}}),
+ ]
+
+
+def _return_edges(source: str, source_handle: str) -> list[dict[str, str]]:
+ return [
+ {
+ "id": "edge-return-value",
+ "type": "default",
+ "source": source,
+ "sourceHandle": source_handle,
+ "target": "return-value",
+ "targetHandle": "value",
+ },
+ {
+ "id": "edge-return-collect",
+ "type": "default",
+ "source": "return-value",
+ "sourceHandle": "value",
+ "target": "return-collect",
+ "targetHandle": "item",
+ },
+ {
+ "id": "edge-return-values",
+ "type": "default",
+ "source": "return-collect",
+ "sourceHandle": "collection",
+ "target": "return",
+ "targetHandle": "values",
+ },
+ ]
+
+
def _services():
return type(
"Services",
@@ -83,18 +122,9 @@ def test_get_workflow_call_compatibility_returns_ok_for_simple_callable_workflow
workflow = _workflow_dump(
nodes=[
_invocation_node("collection", "integer_collection", {"collection": {"value": [1]}}),
- _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
- ],
- edges=[
- {
- "id": "edge-return",
- "type": "default",
- "source": "collection",
- "sourceHandle": "collection",
- "target": "return",
- "targetHandle": "collection",
- }
+ *_return_nodes(),
],
+ edges=_return_edges("collection", "collection"),
)
compatibility = get_workflow_call_compatibility(
@@ -129,8 +159,8 @@ def test_get_workflow_call_compatibility_reports_missing_workflow_return() -> No
def test_get_workflow_call_compatibility_reports_multiple_workflow_return_nodes() -> None:
workflow = _workflow_dump(
nodes=[
- _invocation_node("return-a", "workflow_return", {"collection": {"value": []}}),
- _invocation_node("return-b", "workflow_return", {"collection": {"value": []}}),
+ _invocation_node("return-a", "workflow_return", {"values": {"value": []}}),
+ _invocation_node("return-b", "workflow_return", {"values": {"value": []}}),
],
edges=[],
)
@@ -157,7 +187,7 @@ def test_get_workflow_call_compatibility_reports_unsupported_connected_batch_inp
),
_invocation_node("target", "integer", {"value": {"value": 0}}),
_invocation_node("collect", "collect", {"collection": {"value": []}}),
- _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ *_return_nodes(),
],
edges=[
{
@@ -184,14 +214,7 @@ def test_get_workflow_call_compatibility_reports_unsupported_connected_batch_inp
"target": "collect",
"targetHandle": "item",
},
- {
- "id": "edge-collect-return",
- "type": "default",
- "source": "collect",
- "sourceHandle": "collection",
- "target": "return",
- "targetHandle": "collection",
- },
+ *_return_edges("collect", "collection"),
],
)
@@ -208,13 +231,13 @@ def test_get_workflow_call_compatibility_reports_unsupported_connected_batch_inp
assert "connected batch child workflow inputs" in (compatibility.message or "")
-def test_get_workflow_call_compatibility_allows_batch_directly_into_workflow_return_collection() -> None:
+def test_get_workflow_call_compatibility_allows_batch_returned_by_name() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node(
"batch", "integer_batch", {"integers": {"value": [2, 4]}, "batch_group_id": {"value": "None"}}
),
- _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ *_return_nodes(),
],
edges=[
{
@@ -222,9 +245,10 @@ def test_get_workflow_call_compatibility_allows_batch_directly_into_workflow_ret
"type": "default",
"source": "batch",
"sourceHandle": "integers",
- "target": "return",
- "targetHandle": "collection",
+ "target": "return-value",
+ "targetHandle": "value",
},
+ *_return_edges("return-value", "value")[1:],
],
)
@@ -258,7 +282,7 @@ def test_get_workflow_call_compatibility_can_skip_generator_expansion_for_list_v
},
),
_invocation_node("batch", "image_batch", {"images": {"value": []}, "batch_group_id": {"value": "None"}}),
- _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ *_return_nodes(),
],
edges=[
{
@@ -274,9 +298,10 @@ def test_get_workflow_call_compatibility_can_skip_generator_expansion_for_list_v
"type": "default",
"source": "batch",
"sourceHandle": "images",
- "target": "return",
- "targetHandle": "collection",
+ "target": "return-value",
+ "targetHandle": "value",
},
+ *_return_edges("return-value", "value")[1:],
],
)
@@ -304,7 +329,7 @@ def test_get_workflow_call_compatibility_reports_multiple_batch_inputs_as_unsupp
),
_invocation_node("target", "integer", {"value": {"value": 0}}),
_invocation_node("collect", "collect", {"collection": {"value": []}}),
- _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ *_return_nodes(),
],
edges=[
{
@@ -339,14 +364,7 @@ def test_get_workflow_call_compatibility_reports_multiple_batch_inputs_as_unsupp
"target": "collect",
"targetHandle": "item",
},
- {
- "id": "edge-collect-return",
- "type": "default",
- "source": "collect",
- "sourceHandle": "collection",
- "target": "return",
- "targetHandle": "collection",
- },
+ *_return_edges("collect", "collection"),
],
)
@@ -368,7 +386,7 @@ def test_get_workflow_call_compatibility_allows_workflow_with_required_exposed_i
nodes=[
_invocation_node("target", "integer", {"value": {}}),
_invocation_node("collect", "collect", {"collection": {"value": []}}),
- _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ *_return_nodes(),
],
edges=[
{
@@ -379,14 +397,7 @@ def test_get_workflow_call_compatibility_allows_workflow_with_required_exposed_i
"target": "collect",
"targetHandle": "item",
},
- {
- "id": "edge-collect-return",
- "type": "default",
- "source": "collect",
- "sourceHandle": "collection",
- "target": "return",
- "targetHandle": "collection",
- },
+ *_return_edges("collect", "collection"),
],
)
workflow["exposedFields"] = [{"nodeId": "target", "fieldName": "value"}]
@@ -409,7 +420,7 @@ def test_get_workflow_call_compatibility_allows_workflow_with_required_structure
nodes=[
_invocation_node("template", "prompt_template", {"style_preset": {}}),
_invocation_node("collect", "collect", {"collection": {"value": []}}),
- _invocation_node("return", "workflow_return", {"collection": {"value": []}}),
+ *_return_nodes(),
],
edges=[
{
@@ -420,14 +431,7 @@ def test_get_workflow_call_compatibility_allows_workflow_with_required_structure
"target": "collect",
"targetHandle": "item",
},
- {
- "id": "edge-collect-return",
- "type": "default",
- "source": "collect",
- "sourceHandle": "collection",
- "target": "return",
- "targetHandle": "collection",
- },
+ *_return_edges("collect", "collection"),
],
)
workflow["exposedFields"] = [{"nodeId": "template", "fieldName": "style_preset"}]
diff --git a/tests/app/services/test_workflow_call_runtime.py b/tests/app/services/test_workflow_call_runtime.py
index b53d283b80e..9a57204c208 100644
--- a/tests/app/services/test_workflow_call_runtime.py
+++ b/tests/app/services/test_workflow_call_runtime.py
@@ -67,6 +67,18 @@ def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypa
workflow_call_tests.test_run_completes_call_saved_workflow_with_child_return_collection(monkeypatch)
+def test_run_extracts_named_call_saved_workflow_return(monkeypatch) -> None:
+ workflow_call_tests.test_run_extracts_named_call_saved_workflow_return(monkeypatch)
+
+
+def test_workflow_call_batch_aggregation_rejects_inconsistent_return_keys() -> None:
+ workflow_call_tests.test_workflow_call_batch_aggregation_rejects_inconsistent_return_keys()
+
+
+def test_workflow_call_return_aggregation_failure_cancels_remaining_siblings(monkeypatch) -> None:
+ workflow_call_tests.test_workflow_call_return_aggregation_failure_cancels_remaining_siblings(monkeypatch)
+
+
def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeypatch) -> None:
workflow_call_tests.test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeypatch)
diff --git a/tests/app/services/test_workflow_graph_builder.py b/tests/app/services/test_workflow_graph_builder.py
index 52df9dd2fc5..071f55e46f8 100644
--- a/tests/app/services/test_workflow_graph_builder.py
+++ b/tests/app/services/test_workflow_graph_builder.py
@@ -66,11 +66,48 @@ def _build_workflow(edges: list[dict], nodes: list[dict]):
}
+def _build_named_return_nodes():
+ return [
+ _build_workflow_node("return-value-1", "workflow_return_value", {"key": "result", "value": None}),
+ _build_workflow_node("return-collect-1", "collect", {"collection": []}),
+ _build_workflow_node("return-1", "workflow_return", {"values": []}),
+ ]
+
+
+def _build_named_return_edges(source: str, source_handle: str):
+ return [
+ {
+ "id": "edge-return-value",
+ "type": "default",
+ "source": source,
+ "sourceHandle": source_handle,
+ "target": "return-value-1",
+ "targetHandle": "value",
+ },
+ {
+ "id": "edge-return-collect",
+ "type": "default",
+ "source": "return-value-1",
+ "sourceHandle": "value",
+ "target": "return-collect-1",
+ "targetHandle": "item",
+ },
+ {
+ "id": "edge-return-values",
+ "type": "default",
+ "source": "return-collect-1",
+ "sourceHandle": "collection",
+ "target": "return-1",
+ "targetHandle": "values",
+ },
+ ]
+
+
def test_build_graph_from_workflow_converts_invocation_nodes():
workflow = _build_workflow(
nodes=[
_build_workflow_node("add-1", "add", {"a": 1, "b": 2}),
- _build_workflow_node("return-1", "workflow_return", {"collection": []}),
+ _build_workflow_node("return-1", "workflow_return", {"values": []}),
],
edges=[],
)
@@ -91,7 +128,7 @@ def test_build_graph_from_workflow_flattens_connector_edges():
_build_workflow_node("add-1", "add", {"a": 1, "b": 2}),
_build_connector_node("connector-1"),
_build_workflow_node("add-2", "add", {"a": 999, "b": 3}),
- _build_workflow_node("return-1", "workflow_return", {"collection": []}),
+ *_build_named_return_nodes(),
],
edges=[
{
@@ -110,32 +147,29 @@ def test_build_graph_from_workflow_flattens_connector_edges():
"target": "add-2",
"targetHandle": "a",
},
- {
- "id": "edge-3",
- "type": "default",
- "source": "add-2",
- "sourceHandle": "value",
- "target": "return-1",
- "targetHandle": "collection",
- },
+ *_build_named_return_edges("add-2", "value"),
],
)
graph = build_graph_from_workflow(workflow)
- assert len(graph.edges) == 2
- first_edge, second_edge = graph.edges
+ assert len(graph.edges) == 4
+ first_edge, second_edge, third_edge, fourth_edge = graph.edges
assert first_edge.source.node_id == "add-1"
assert first_edge.source.field == "value"
assert first_edge.destination.node_id == "add-2"
assert first_edge.destination.field == "a"
assert second_edge.source.node_id == "add-2"
assert second_edge.source.field == "value"
- assert second_edge.destination.node_id == "return-1"
- assert second_edge.destination.field == "collection"
+ assert second_edge.destination.node_id == "return-value-1"
+ assert second_edge.destination.field == "value"
+ assert third_edge.destination.node_id == "return-collect-1"
+ assert third_edge.destination.field == "item"
+ assert fourth_edge.destination.node_id == "return-1"
+ assert fourth_edge.destination.field == "values"
assert graph.nodes["add-2"].a == 0
assert graph.nodes["add-2"].b == 3
- assert graph.nodes["return-1"].collection == []
+ assert graph.nodes["return-1"].values == []
def test_build_graph_from_workflow_rejects_batch_special_nodes_with_clear_error():
@@ -161,8 +195,8 @@ def test_build_graph_from_workflow_rejects_workflows_without_workflow_return():
def test_build_graph_from_workflow_rejects_workflows_with_multiple_workflow_return_nodes():
workflow = _build_workflow(
nodes=[
- _build_workflow_node("return-1", "workflow_return", {"collection": []}),
- _build_workflow_node("return-2", "workflow_return", {"collection": []}),
+ _build_workflow_node("return-1", "workflow_return", {"values": []}),
+ _build_workflow_node("return-2", "workflow_return", {"values": []}),
],
edges=[],
)
diff --git a/tests/app/services/workflow_call_test_utils.py b/tests/app/services/workflow_call_test_utils.py
index ffa277fe83c..f6681b61d3d 100644
--- a/tests/app/services/workflow_call_test_utils.py
+++ b/tests/app/services/workflow_call_test_utils.py
@@ -10,6 +10,11 @@
from invokeai.app.invocations.fields import InputField, OutputField
from invokeai.app.invocations.logic import IfInvocation
from invokeai.app.invocations.math import AddInvocation
+from invokeai.app.invocations.workflow_return import (
+ WorkflowReturnGetInvocation,
+ WorkflowReturnInvocation,
+ WorkflowReturnOutput,
+)
from invokeai.app.services.session_processor.session_processor_default import (
DefaultSessionProcessor,
DefaultSessionRunner,
@@ -119,6 +124,61 @@ def _invocation_node(node_id: str, invocation_type: str, inputs: dict[str, Any])
},
}
+ @classmethod
+ def _return_value_nodes(
+ cls,
+ *,
+ key: str = "result",
+ value_node_id: str = "child-return-value",
+ collect_node_id: str = "child-return-collect",
+ return_node_id: str = "child-return",
+ ) -> list[dict[str, Any]]:
+ return [
+ cls._invocation_node(
+ value_node_id,
+ "workflow_return_value",
+ {"key": {"value": key}, "value": {"value": None}},
+ ),
+ cls._invocation_node(collect_node_id, "collect", {"collection": {"value": []}}),
+ cls._invocation_node(return_node_id, "workflow_return", {"values": {"value": []}}),
+ ]
+
+ @staticmethod
+ def _return_value_edges(
+ *,
+ source: str,
+ source_handle: str,
+ value_node_id: str = "child-return-value",
+ collect_node_id: str = "child-return-collect",
+ return_node_id: str = "child-return",
+ ) -> list[dict[str, str]]:
+ return [
+ {
+ "id": f"edge-{source}-return-value",
+ "type": "default",
+ "source": source,
+ "sourceHandle": source_handle,
+ "target": value_node_id,
+ "targetHandle": "value",
+ },
+ {
+ "id": f"edge-{value_node_id}-collect",
+ "type": "default",
+ "source": value_node_id,
+ "sourceHandle": "value",
+ "target": collect_node_id,
+ "targetHandle": "item",
+ },
+ {
+ "id": f"edge-{collect_node_id}-return",
+ "type": "default",
+ "source": collect_node_id,
+ "sourceHandle": "collection",
+ "target": return_node_id,
+ "targetHandle": "values",
+ },
+ ]
+
@classmethod
def _workflow_dump(
cls,
@@ -158,22 +218,9 @@ def get(self, workflow_id: str):
"integer_collection",
{"collection": {"value": [3]}},
),
- self._invocation_node(
- "child-return",
- "workflow_return",
- {"collection": {"value": []}},
- ),
- ],
- edges=[
- {
- "id": "edge-default-return",
- "type": "default",
- "source": "child-collection",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
- }
+ *self._return_value_nodes(),
],
+ edges=self._return_value_edges(source="child-collection", source_handle="collection"),
exposed_fields=[{"nodeId": "child-add", "fieldName": self.exposed_field_name}],
)
if self.return_invalid_workflow:
@@ -214,11 +261,7 @@ def get(self, workflow_id: str):
"integer_collection",
{"collection": {"value": [7]}},
),
- self._invocation_node(
- "child-return",
- "workflow_return",
- {"collection": {"value": []}},
- ),
+ *self._return_value_nodes(),
],
edges=[
{
@@ -229,14 +272,7 @@ def get(self, workflow_id: str):
"target": "child-add-2",
"targetHandle": "a",
},
- {
- "id": "edge-dependent-return",
- "type": "default",
- "source": "child-collection",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
- },
+ *self._return_value_edges(source="child-collection", source_handle="collection"),
],
)
elif workflow_id == "workflow-if":
@@ -258,11 +294,7 @@ def get(self, workflow_id: str):
"false_input": {"value": 11},
},
),
- self._invocation_node(
- "child-return",
- "workflow_return",
- {"collection": {"value": []}},
- ),
+ *self._return_value_nodes(),
],
edges=[
{
@@ -281,14 +313,7 @@ def get(self, workflow_id: str):
"target": "child-if",
"targetHandle": "true_input",
},
- {
- "id": "edge-if-return",
- "type": "default",
- "source": "child-collection",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
- },
+ *self._return_value_edges(source="child-collection", source_handle="collection"),
],
)
elif workflow_id == "workflow-nested":
@@ -308,22 +333,19 @@ def get(self, workflow_id: str):
"integer_collection",
{"collection": {"value": [4]}},
),
- self._invocation_node(
- "nested-return",
- "workflow_return",
- {"collection": {"value": []}},
+ *self._return_value_nodes(
+ value_node_id="nested-return-value",
+ collect_node_id="nested-return-collect",
+ return_node_id="nested-return",
),
],
- edges=[
- {
- "id": "edge-nested-return",
- "type": "default",
- "source": "nested-collection",
- "sourceHandle": "collection",
- "target": "nested-return",
- "targetHandle": "collection",
- }
- ],
+ edges=self._return_value_edges(
+ source="nested-collection",
+ source_handle="collection",
+ value_node_id="nested-return-value",
+ collect_node_id="nested-return-collect",
+ return_node_id="nested-return",
+ ),
)
elif workflow_id == "workflow-leaf":
workflow_dump = self._workflow_dump(
@@ -334,22 +356,19 @@ def get(self, workflow_id: str):
"integer_collection",
{"collection": {"value": [11]}},
),
- self._invocation_node(
- "leaf-return",
- "workflow_return",
- {"collection": {"value": []}},
+ *self._return_value_nodes(
+ value_node_id="leaf-return-value",
+ collect_node_id="leaf-return-collect",
+ return_node_id="leaf-return",
),
],
- edges=[
- {
- "id": "edge-leaf-return",
- "type": "default",
- "source": "leaf-collection",
- "sourceHandle": "collection",
- "target": "leaf-return",
- "targetHandle": "collection",
- }
- ],
+ edges=self._return_value_edges(
+ source="leaf-collection",
+ source_handle="collection",
+ value_node_id="leaf-return-value",
+ collect_node_id="leaf-return-collect",
+ return_node_id="leaf-return",
+ ),
)
elif workflow_id == "workflow-nested-no-return":
workflow_dump = self._workflow_dump(
@@ -367,46 +386,65 @@ def get(self, workflow_id: str):
"integer_collection",
{"collection": {"value": [4]}},
),
- self._invocation_node(
- "nested-return",
- "workflow_return",
- {"collection": {"value": []}},
+ *self._return_value_nodes(
+ value_node_id="nested-return-value",
+ collect_node_id="nested-return-collect",
+ return_node_id="nested-return",
),
],
- edges=[
- {
- "id": "edge-nested-return",
- "type": "default",
- "source": "nested-collection",
- "sourceHandle": "collection",
- "target": "nested-return",
- "targetHandle": "collection",
- }
- ],
+ edges=self._return_value_edges(
+ source="nested-collection",
+ source_handle="collection",
+ value_node_id="nested-return-value",
+ collect_node_id="nested-return-collect",
+ return_node_id="nested-return",
+ ),
)
elif workflow_id == "workflow-return":
workflow_dump = self._workflow_dump(
nodes=[
self._invocation_node(
- "child-collection",
+ "child-value",
"integer_collection",
{"collection": {"value": [7, 8]}},
),
+ self._invocation_node(
+ "child-return-value",
+ "workflow_return_value",
+ {"key": {"value": "numbers"}, "value": {"value": None}},
+ ),
+ self._invocation_node("child-return-collect", "collect", {"collection": {"value": []}}),
self._invocation_node(
"child-return",
"workflow_return",
- {"collection": {"value": []}},
+ {"values": {"value": []}},
),
],
edges=[
{
- "id": "edge-return-collection",
+ "id": "edge-return-value",
+ "type": "default",
+ "source": "child-value",
+ "sourceHandle": "collection",
+ "target": "child-return-value",
+ "targetHandle": "value",
+ },
+ {
+ "id": "edge-return-collect",
+ "type": "default",
+ "source": "child-return-value",
+ "sourceHandle": "value",
+ "target": "child-return-collect",
+ "targetHandle": "item",
+ },
+ {
+ "id": "edge-return-values",
"type": "default",
- "source": "child-collection",
+ "source": "child-return-collect",
"sourceHandle": "collection",
"target": "child-return",
- "targetHandle": "collection",
- }
+ "targetHandle": "values",
+ },
],
)
elif workflow_id == "workflow-no-return":
@@ -436,8 +474,13 @@ def get(self, workflow_id: str):
},
),
self._invocation_node("child-int", "integer", {"value": {"value": 0}}),
- self._invocation_node("child-collect", "collect", {"collection": {"value": []}}),
- self._invocation_node("child-return", "workflow_return", {"collection": {"value": []}}),
+ self._invocation_node(
+ "child-return-value",
+ "workflow_return_value",
+ {"key": {"value": "number"}, "value": {"value": None}},
+ ),
+ self._invocation_node("child-return-collect", "collect", {"collection": {"value": []}}),
+ self._invocation_node("child-return", "workflow_return", {"values": {"value": []}}),
],
edges=[
{
@@ -449,20 +492,28 @@ def get(self, workflow_id: str):
"targetHandle": "value",
},
{
- "id": "edge-int-collect",
+ "id": "edge-int-return-value",
"type": "default",
"source": "child-int",
"sourceHandle": "value",
- "target": "child-collect",
+ "target": "child-return-value",
+ "targetHandle": "value",
+ },
+ {
+ "id": "edge-return-value-collect",
+ "type": "default",
+ "source": "child-return-value",
+ "sourceHandle": "value",
+ "target": "child-return-collect",
"targetHandle": "item",
},
{
- "id": "edge-collect-return",
+ "id": "edge-return-values",
"type": "default",
- "source": "child-collect",
+ "source": "child-return-collect",
"sourceHandle": "collection",
"target": "child-return",
- "targetHandle": "collection",
+ "targetHandle": "values",
},
],
)
@@ -486,8 +537,7 @@ def get(self, workflow_id: str):
},
),
self._invocation_node("child-add", "add", {"a": {"value": 0}, "b": {"value": 0}}),
- self._invocation_node("child-collect", "collect", {"collection": {"value": []}}),
- self._invocation_node("child-return", "workflow_return", {"collection": {"value": []}}),
+ *self._return_value_nodes(),
],
edges=[
{
@@ -507,21 +557,10 @@ def get(self, workflow_id: str):
"targetHandle": "b",
},
{
- "id": "edge-group-collect",
- "type": "default",
- "source": "child-add",
- "sourceHandle": "value",
- "target": "child-collect",
- "targetHandle": "item",
- },
- {
- "id": "edge-group-return",
- "type": "default",
- "source": "child-collect",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
+ **self._return_value_edges(source="child-add", source_handle="value")[0],
+ "id": "edge-group-return-value",
},
+ *self._return_value_edges(source="child-add", source_handle="value")[1:],
],
)
elif workflow_id == "workflow-batch-cartesian":
@@ -544,8 +583,7 @@ def get(self, workflow_id: str):
},
),
self._invocation_node("child-add", "add", {"a": {"value": 0}, "b": {"value": 0}}),
- self._invocation_node("child-collect", "collect", {"collection": {"value": []}}),
- self._invocation_node("child-return", "workflow_return", {"collection": {"value": []}}),
+ *self._return_value_nodes(),
],
edges=[
{
@@ -564,22 +602,7 @@ def get(self, workflow_id: str):
"target": "child-add",
"targetHandle": "b",
},
- {
- "id": "edge-cart-collect",
- "type": "default",
- "source": "child-add",
- "sourceHandle": "value",
- "target": "child-collect",
- "targetHandle": "item",
- },
- {
- "id": "edge-cart-return",
- "type": "default",
- "source": "child-collect",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
- },
+ *self._return_value_edges(source="child-add", source_handle="value"),
],
)
elif workflow_id == "workflow-batch-failure":
@@ -594,8 +617,7 @@ def get(self, workflow_id: str):
},
),
self._invocation_node("child-guard", "test_fail_on_integer", {"value": {"value": 0}}),
- self._invocation_node("child-collect", "collect", {"collection": {"value": []}}),
- self._invocation_node("child-return", "workflow_return", {"collection": {"value": []}}),
+ *self._return_value_nodes(),
],
edges=[
{
@@ -606,22 +628,7 @@ def get(self, workflow_id: str):
"target": "child-guard",
"targetHandle": "value",
},
- {
- "id": "edge-failure-collect",
- "type": "default",
- "source": "child-guard",
- "sourceHandle": "value",
- "target": "child-collect",
- "targetHandle": "item",
- },
- {
- "id": "edge-failure-return",
- "type": "default",
- "source": "child-collect",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
- },
+ *self._return_value_edges(source="child-guard", source_handle="value"),
],
)
elif workflow_id == "workflow-batch-generator":
@@ -637,8 +644,7 @@ def get(self, workflow_id: str):
},
),
self._invocation_node("child-int", "integer", {"value": {"value": 0}}),
- self._invocation_node("child-collect", "collect", {"collection": {"value": []}}),
- self._invocation_node("child-return", "workflow_return", {"collection": {"value": []}}),
+ *self._return_value_nodes(),
],
edges=[
{
@@ -657,22 +663,7 @@ def get(self, workflow_id: str):
"target": "child-int",
"targetHandle": "value",
},
- {
- "id": "edge-generator-collect",
- "type": "default",
- "source": "child-int",
- "sourceHandle": "value",
- "target": "child-collect",
- "targetHandle": "item",
- },
- {
- "id": "edge-generator-return",
- "type": "default",
- "source": "child-collect",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
- },
+ *self._return_value_edges(source="child-int", source_handle="value"),
],
)
elif workflow_id == "workflow-batch-generator-integer":
@@ -701,8 +692,7 @@ def get(self, workflow_id: str):
},
),
self._invocation_node("child-int", "integer", {"value": {"value": 0}}),
- self._invocation_node("child-collect", "collect", {"collection": {"value": []}}),
- self._invocation_node("child-return", "workflow_return", {"collection": {"value": []}}),
+ *self._return_value_nodes(),
],
edges=[
{
@@ -721,22 +711,7 @@ def get(self, workflow_id: str):
"target": "child-int",
"targetHandle": "value",
},
- {
- "id": "edge-generator-collect",
- "type": "default",
- "source": "child-int",
- "sourceHandle": "value",
- "target": "child-collect",
- "targetHandle": "item",
- },
- {
- "id": "edge-generator-return",
- "type": "default",
- "source": "child-collect",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
- },
+ *self._return_value_edges(source="child-int", source_handle="value"),
],
)
elif workflow_id == "workflow-batch-generator-image":
@@ -764,8 +739,7 @@ def get(self, workflow_id: str):
},
),
self._invocation_node("child-image", "image", {"image": {"value": None}}),
- self._invocation_node("child-collect", "collect", {"collection": {"value": []}}),
- self._invocation_node("child-return", "workflow_return", {"collection": {"value": []}}),
+ *self._return_value_nodes(),
],
edges=[
{
@@ -784,22 +758,7 @@ def get(self, workflow_id: str):
"target": "child-image",
"targetHandle": "image",
},
- {
- "id": "edge-generator-collect",
- "type": "default",
- "source": "child-image",
- "sourceHandle": "image",
- "target": "child-collect",
- "targetHandle": "item",
- },
- {
- "id": "edge-generator-return",
- "type": "default",
- "source": "child-collect",
- "sourceHandle": "collection",
- "target": "child-return",
- "targetHandle": "collection",
- },
+ *self._return_value_edges(source="child-image", source_handle="image"),
],
)
@@ -1406,8 +1365,10 @@ def test_workflow_call_coordinator_suspends_parent_and_enqueues_child_queue_item
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ graph.add_node(WorkflowReturnGetInvocation(id="get-return", key="result"))
graph.add_node(IfInvocation(id="downstream-if", condition=True, false_input=0))
- graph.add_edge(create_edge("call-node", "collection", "downstream-if", "true_input"))
+ graph.add_edge(create_edge("call-node", "values", "get-return", "values"))
+ graph.add_edge(create_edge("get-return", "value", "downstream-if", "true_input"))
session = GraphExecutionState(graph=graph)
queue_item = type(
@@ -1521,7 +1482,7 @@ def test_workflow_call_queue_lifecycle_resumes_parent_from_completed_child(
output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
]
assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [3]
+ assert parent_outputs[0].values == {"result": [3]}
def test_workflow_call_coordinator_cleans_up_enqueued_children_when_boundary_setup_fails(
@@ -1666,8 +1627,10 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
graph = Graph()
graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ graph.add_node(WorkflowReturnGetInvocation(id="get-return", key="result"))
graph.add_node(IfInvocation(id="downstream-if", condition=True, false_input=0))
- graph.add_edge(create_edge("call-node", "collection", "downstream-if", "true_input"))
+ graph.add_edge(create_edge("call-node", "values", "get-return", "values"))
+ graph.add_edge(create_edge("get-return", "value", "downstream-if", "true_input"))
session = GraphExecutionState(graph=graph)
queue_item = type(
@@ -1688,15 +1651,17 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
assert not session.is_waiting_on_workflow_call()
assert "downstream-if" in session.executed
- assert len(events.started) == 5
assert [invocation.get_type() for _queue_item, invocation in events.started] == [
"call_saved_workflow",
"add",
"integer_collection",
+ "workflow_return_value",
+ "collect",
"workflow_return",
+ "workflow_return_get",
"if",
]
- assert len(events.completed) == 5
+ assert len(events.completed) == 8
parent_outputs = [
output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
]
@@ -1704,7 +1669,7 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
output for invocation, _queue_item, output in events.completed if invocation.get_type() == "if"
]
assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [3]
+ assert parent_outputs[0].values == {"result": [3]}
assert len(downstream_outputs) == 1
assert downstream_outputs[0].value == [3]
assert events.errors == []
@@ -1790,7 +1755,7 @@ def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch
output for invocation, _queue_item, output in events.completed if invocation.get_type() == "call_saved_workflow"
]
assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [3]
+ assert parent_outputs[0].values == {"result": [3]}
assert events.errors == []
@@ -1830,13 +1795,49 @@ def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypa
]
assert len(child_return_outputs) == 1
- assert child_return_outputs[0].collection == [7, 8]
+ assert child_return_outputs[0].values == {"numbers": [7, 8]}
assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [7, 8]
+ assert parent_outputs[0].values == {"numbers": [7, 8]}
assert session_queue.completed_item_ids == [100, 1]
assert events.errors == []
+def test_run_extracts_named_call_saved_workflow_return(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-return"))
+ graph.add_node(WorkflowReturnGetInvocation(id="get-return", key="numbers"))
+ graph.add_edge(create_edge("call-node", "values", "get-return", "values"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
+ },
+ )()
+
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_queue_lifecycle, session_queue, queue_item)
+
+ extracted_outputs = [
+ output for invocation, _queue_item, output in events.completed if invocation.get_type() == "workflow_return_get"
+ ]
+
+ assert len(extracted_outputs) == 1
+ assert extracted_outputs[0].value == [7, 8]
+ assert events.errors == []
+
+
def test_run_completes_call_saved_workflow_with_batched_child_returns(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
@@ -1876,10 +1877,92 @@ def test_run_completes_call_saved_workflow_with_batched_child_returns(monkeypatc
] == [[("child-int", "value", 2)], [("child-int", "value", 4)], [("child-int", "value", 6)]]
assert session_queue.completed_item_ids == [100, 101, 102, 1]
assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [2, 4, 6]
+ assert parent_outputs[0].values == {"number": [2, 4, 6]}
assert events.errors == []
+def test_workflow_call_batch_aggregation_rejects_inconsistent_return_keys() -> None:
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ session = GraphExecutionState(graph=graph)
+ session.begin_waiting_on_workflow_call(
+ WorkflowCallFrame(
+ prepared_call_node_id="call-node",
+ source_call_node_id="call-node",
+ workflow_id="workflow-a",
+ depth=1,
+ )
+ )
+ session.waiting_workflow_call_execution.expected_child_count = 2
+
+ session.record_waiting_workflow_call_child_completion(100, {"image": "image-a"})
+
+ with pytest.raises(ValueError, match="returned different workflow return keys"):
+ session.record_waiting_workflow_call_child_completion(101, {"mask": "mask-a"})
+
+
+def test_workflow_call_return_aggregation_failure_cancels_remaining_siblings(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ lifecycle = WorkflowCallQueueLifecycle(runner)
+
+ parent_graph = Graph()
+ parent_graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ parent_session = GraphExecutionState(graph=parent_graph)
+ parent_invocation = parent_session.next()
+ assert isinstance(parent_invocation, CallSavedWorkflowInvocation)
+ parent_session.begin_waiting_on_workflow_call(
+ parent_session.build_workflow_call_frame(parent_invocation.id, "workflow-a")
+ )
+ parent_session.waiting_workflow_call_execution.expected_child_count = 2
+ parent_session.record_waiting_workflow_call_child_completion(100, {"image": "image-a"})
+ workflow_call_id = parent_session.waiting_workflow_call_execution.id
+ parent_queue_item = SimpleNamespace(
+ item_id=1,
+ status="waiting",
+ session=parent_session,
+ session_id=parent_session.id,
+ user_id="user-1",
+ queue_id="default",
+ batch_id="batch-1",
+ )
+ session_queue.add_queue_item(parent_queue_item)
+
+ child_graph = Graph()
+ child_graph.add_node(WorkflowReturnInvocation(id="return"))
+ child_session = GraphExecutionState(graph=child_graph)
+ return_invocation = child_session.next()
+ assert isinstance(return_invocation, WorkflowReturnInvocation)
+ child_session.complete(return_invocation.id, WorkflowReturnOutput(values={"mask": "mask-a"}))
+ child_queue_item = SimpleNamespace(
+ item_id=101,
+ status="completed",
+ session=child_session,
+ session_id=child_session.id,
+ parent_item_id=1,
+ workflow_call_id=workflow_call_id,
+ )
+ sibling_queue_item = SimpleNamespace(
+ item_id=102,
+ status="pending",
+ session=GraphExecutionState(graph=Graph()),
+ session_id="sibling-session",
+ parent_item_id=1,
+ workflow_call_id=workflow_call_id,
+ )
+ session_queue.add_queue_item(child_queue_item)
+ session_queue.add_queue_item(sibling_queue_item)
+
+ lifecycle._resume_parent_from_completed_child(child_queue_item)
+
+ assert session_queue.failed_item_ids == [1]
+ assert session_queue.canceled_item_ids == [102]
+ assert len(events.errors) == 1
+ assert "different workflow return keys" in events.errors[0][3]
+
+
def test_run_zips_grouped_batch_children(monkeypatch: pytest.MonkeyPatch) -> None:
session_queue = _DummySessionQueue()
runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
@@ -1911,7 +1994,7 @@ def test_run_zips_grouped_batch_children(monkeypatch: pytest.MonkeyPatch) -> Non
assert session_queue.enqueued_child_item_ids == [100, 101, 102]
assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [11, 22, 33]
+ assert parent_outputs[0].values == {"result": [11, 22, 33]}
def test_run_expands_ungrouped_batch_children_as_cartesian_product(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -1945,7 +2028,7 @@ def test_run_expands_ungrouped_batch_children_as_cartesian_product(monkeypatch:
assert session_queue.enqueued_child_item_ids == [100, 101, 102, 103]
assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [11, 21, 12, 22]
+ assert parent_outputs[0].values == {"result": [11, 21, 12, 22]}
def test_run_fails_batched_child_workflow_and_cancels_remaining_siblings(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -2011,7 +2094,7 @@ def test_run_supports_generator_backed_integer_batched_child_workflow(monkeypatc
assert session_queue.enqueued_child_item_ids == [100, 101, 102]
assert len(parent_outputs) == 1
- assert parent_outputs[0].collection == [2, 4, 6]
+ assert parent_outputs[0].values == {"result": [2, 4, 6]}
assert events.errors == []
@@ -2046,7 +2129,7 @@ def test_run_supports_generator_backed_image_batched_child_workflow(monkeypatch:
assert session_queue.enqueued_child_item_ids == [100, 101]
assert len(parent_outputs) == 1
- assert [item.image_name for item in parent_outputs[0].collection] == ["img-a", "img-b"]
+ assert [item.image_name for item in parent_outputs[0].values["result"]] == ["img-a", "img-b"]
assert events.errors == []
From fefebb221e91d81c0a549f5ef81f5a5744adbaaf Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 12:42:21 -0500
Subject: [PATCH 091/100] chore: lint
---
.../Invocation/fields/inputs/savedWorkflowFieldUtils.ts | 6 +++---
.../components/QueueList/getQueueItemActionVisibility.ts | 2 +-
.../features/queue/components/common/QueueStatusBadge.tsx | 2 +-
.../workflowLibrary/util/workflowCallCompatibility.ts | 2 +-
.../workflowLibrary/util/workflowLibraryListItemState.ts | 2 +-
5 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
index f64fe14d11e..ca9ac0271a5 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/savedWorkflowFieldUtils.ts
@@ -3,8 +3,8 @@ import { getWorkflowCallCompatibilityState } from 'features/workflowLibrary/util
import type { S, WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
-export const SAVED_WORKFLOW_PICKER_PAGE_SIZE = 50;
-export type SavedWorkflowBadge = 'unsupported' | 'default' | 'shared';
+const SAVED_WORKFLOW_PICKER_PAGE_SIZE = 50;
+type SavedWorkflowBadge = 'unsupported' | 'default' | 'shared';
type SavedWorkflowSelectionState =
| { status: 'unselected' }
@@ -120,7 +120,7 @@ export const getSavedWorkflowSelectionOption = (selectionState: SavedWorkflowSel
};
};
-export type SavedWorkflowDisplayState =
+type SavedWorkflowDisplayState =
| {
selection: 'unselected' | 'missing';
statusLabelKey: 'nodes.savedWorkflowChoose' | 'nodes.savedWorkflowMissing';
diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/getQueueItemActionVisibility.ts b/invokeai/frontend/web/src/features/queue/components/QueueList/getQueueItemActionVisibility.ts
index 36143f92586..b9576a2b6f5 100644
--- a/invokeai/frontend/web/src/features/queue/components/QueueList/getQueueItemActionVisibility.ts
+++ b/invokeai/frontend/web/src/features/queue/components/QueueList/getQueueItemActionVisibility.ts
@@ -1,6 +1,6 @@
import type { S } from 'services/api/types';
-export const isChildQueueItem = (item: S['SessionQueueItem']): boolean =>
+const isChildQueueItem = (item: S['SessionQueueItem']): boolean =>
item.parent_item_id !== null && item.parent_item_id !== undefined;
export const getQueueItemActionVisibility = (item: S['SessionQueueItem']) => ({
diff --git a/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.tsx b/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.tsx
index ce26b7e9065..780844ee427 100644
--- a/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/common/QueueStatusBadge.tsx
@@ -3,7 +3,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { SessionQueueItemStatus } from 'services/api/endpoints/queue';
-export const QUEUE_STATUS_BADGE_STATES = {
+const QUEUE_STATUS_BADGE_STATES = {
pending: { colorScheme: 'cyan', translationKey: 'queue.pending' },
in_progress: { colorScheme: 'yellow', translationKey: 'queue.in_progress' },
waiting: { colorScheme: 'purple', translationKey: 'queue.waiting' },
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowCallCompatibility.ts b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowCallCompatibility.ts
index ec7cf75bbeb..7c9148bc1c7 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowCallCompatibility.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowCallCompatibility.ts
@@ -1,6 +1,6 @@
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
-export type WorkflowCallCompatibilityState =
+type WorkflowCallCompatibilityState =
| {
isUnsupported: false;
message: null;
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts
index 943ef67d97b..3f5859cec90 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts
@@ -1,7 +1,7 @@
import { getWorkflowCallCompatibilityState } from 'features/workflowLibrary/util/workflowCallCompatibility';
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
-export type WorkflowLibraryListItemState = {
+type WorkflowLibraryListItemState = {
showUnsupportedBadge: boolean;
unsupportedMessage: string | null;
showSharedBadge: boolean;
From b9707df6abeb672fe749b211500810fab6dd0b4b Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 13:21:34 -0500
Subject: [PATCH 092/100] Remove noise in tests
---
.../nodes/util/workflow/validateWorkflow.test.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
index d1c85be1675..28bc5915fcf 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts
@@ -5,7 +5,17 @@ import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { getDefaultForm } from 'features/nodes/types/workflow';
import { buildInvocationNode } from 'features/nodes/util/node/buildInvocationNode';
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('app/logging/logger', () => ({
+ logger: () => ({
+ trace: vi.fn(),
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
//TODO(psyche): Test workflow validation for form builder fields
describe('validateWorkflow', () => {
From a40ae52b83f59942f613d47613c88028cf6af54c Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 14:09:39 -0500
Subject: [PATCH 093/100] Bugfixes and optimization - workflow_return can now
take a single or collection
---
docs/contributing/call_saved_workflow.md | 12 ++-
invokeai/app/invocations/workflow_return.py | 5 +-
.../session_processor_default.py | 1 +
invokeai/app/services/shared/README.md | 2 +
.../shared/workflow_call_compatibility.py | 4 +-
.../frontend/web/src/services/api/schema.ts | 2 +-
.../invocations/test_call_saved_workflows.py | 10 +++
.../test_workflow_call_compatibility.py | 79 +++++++++++++++++++
.../services/test_workflow_call_runtime.py | 4 +
.../app/services/workflow_call_test_utils.py | 14 ++++
10 files changed, 125 insertions(+), 8 deletions(-)
diff --git a/docs/contributing/call_saved_workflow.md b/docs/contributing/call_saved_workflow.md
index d1399a0e13b..3e598ef8918 100644
--- a/docs/contributing/call_saved_workflow.md
+++ b/docs/contributing/call_saved_workflow.md
@@ -37,7 +37,8 @@ Implemented already in the branch:
- A real invocation exists: `call_saved_workflow`.
- A real return node exists: `workflow_return`.
- Named returns exist through `workflow_return_value`, `workflow_return`, and caller-side `workflow_return_get`.
-- `workflow_return` accepts collected key/value return members and emits a named `values: dict[str, Any]` map.
+- `workflow_return` accepts one key/value return member directly or a collected list of return members, then emits a
+ named `values: dict[str, Any]` map.
- Only one `workflow_return` node is allowed per workflow, enforced in both frontend validation and Python validation.
- The frontend provides a saved-workflow picker using a reusable `SavedWorkflowField` UI type.
- The node redraws dynamically based on the selected saved workflow's exposed form fields.
@@ -467,7 +468,8 @@ Do not infer child outputs from arbitrary terminal nodes. That is too ambiguous
Named return contract:
- the called workflow builds return members with a dedicated key/value node
-- `workflow_return` accepts a collection of those return members
+- `workflow_return` accepts one return member directly, or a collected list of return members when the workflow returns
+ multiple named values
- non-batch execution rejects duplicate return keys
- if a non-batch workflow needs to return multiple images under one key, the child workflow should collect those images
into one list value and return that list under the key
@@ -596,7 +598,8 @@ frontend state.
The intended runtime flow is:
1. The child workflow computes named return members like ordinary node outputs.
-1. The child workflow collects those members into the `workflow_return` node.
+1. The child workflow connects one return member directly to `workflow_return.values`, or collects multiple return
+ members and connects that list to `workflow_return.values`.
1. When the child reaches `workflow_return`, runtime captures the resolved named return map as the child workflow
result.
1. The child workflow result is stored in child execution state.
@@ -621,7 +624,7 @@ Contract:
- `WorkflowReturnValueField` stores one `key: str` and one `value: Any`
- `workflow_return_value` creates a single `WorkflowReturnValueField` from a key and connected value
-- `workflow_return` accepts a collection of `WorkflowReturnValueField` members
+- `workflow_return` accepts either one `WorkflowReturnValueField` member or a list of `WorkflowReturnValueField` members
- `WorkflowReturnOutput` exposes `values: dict[str, Any]`
- duplicate keys in one non-batch `workflow_return` execution are invalid and must fail clearly
@@ -717,6 +720,7 @@ Contract:
Tests first:
- frontend connection/type tests cover return-value collection wiring
+- frontend connection/type tests cover wiring one `workflow_return_value.value` directly to `workflow_return.values`
- frontend connection/type tests cover `call_saved_workflow.values -> workflow_return_get.values`
- docs describe how a called workflow creates named returns and how a caller extracts them
diff --git a/invokeai/app/invocations/workflow_return.py b/invokeai/app/invocations/workflow_return.py
index 9e0dbd69d59..494931e06a7 100644
--- a/invokeai/app/invocations/workflow_return.py
+++ b/invokeai/app/invocations/workflow_return.py
@@ -82,7 +82,7 @@ def invoke(self, context: InvocationContext) -> WorkflowReturnValueOutput:
class WorkflowReturnInvocation(BaseInvocation):
"""Defines the explicit named result returned by a callable workflow."""
- values: list[WorkflowReturnValueField] = InputField(
+ values: WorkflowReturnValueField | list[WorkflowReturnValueField] = InputField(
default=[],
description="The named values returned to a calling workflow.",
title="Values",
@@ -91,7 +91,8 @@ class WorkflowReturnInvocation(BaseInvocation):
def invoke(self, context: InvocationContext) -> WorkflowReturnOutput:
named_values: dict[str, Any] = {}
- for value in self.values:
+ return_values = self.values if isinstance(self.values, list) else [self.values]
+ for value in return_values:
key = value.key.strip()
if not key:
raise ValueError("Workflow return key must not be empty.")
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index 136c65f87bb..bbfa8d4f40b 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -329,6 +329,7 @@ def __init__(
super().__init__()
self.session_runner = session_runner if session_runner else DefaultSessionRunner()
+ self.workflow_call_queue_lifecycle = self.session_runner.workflow_call_queue_lifecycle
self._on_non_fatal_processor_error_callbacks = on_non_fatal_processor_error_callbacks or []
self._thread_limit = thread_limit
self._polling_interval = polling_interval
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
index 41b99088a96..c6603f2c5d2 100644
--- a/invokeai/app/services/shared/README.md
+++ b/invokeai/app/services/shared/README.md
@@ -301,6 +301,8 @@ Current limitation:
handled by a dedicated workflow-call queue lifecycle component for this PR because no other feature currently needs a
generalized dependent-queue scheduler.
- Called workflows currently require exactly one valid `workflow_return` node to be callable at all.
+- A single `workflow_return_value.value` may connect directly to `workflow_return.values`; multiple named return members
+ should be collected and then connected to `workflow_return.values`.
- Direct batch-special child workflows are now supported by expanding them into multiple child queue rows.
- Batch outputs may feed a named `workflow_return_value.value` directly. Parent resume aggregates named return maps as
`values: dict[str, list[Any]]`, and all rows in one batch call must return the same key set.
diff --git a/invokeai/app/services/shared/workflow_call_compatibility.py b/invokeai/app/services/shared/workflow_call_compatibility.py
index a4de59044b2..741aba5a0b0 100644
--- a/invokeai/app/services/shared/workflow_call_compatibility.py
+++ b/invokeai/app/services/shared/workflow_call_compatibility.py
@@ -194,8 +194,10 @@ def get_workflow_call_compatibility(
reason = WorkflowCallCompatibilityReason.UnsupportedNode
if _is_unsupported_batch_input_message(message):
reason = WorkflowCallCompatibilityReason.UnsupportedBatchInput
- elif "exactly one workflow_return" in message:
+ elif "exactly one workflow_return" in message and workflow_return_count == 0:
reason = WorkflowCallCompatibilityReason.MissingWorkflowReturn
+ elif "exactly one workflow_return" in message:
+ reason = WorkflowCallCompatibilityReason.InvalidGraph
return WorkflowCallCompatibility(
is_callable=False,
reason=reason,
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 122cf4fcf70..dbc981e152e 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -31698,7 +31698,7 @@ export type components = {
* @description The named values returned to a calling workflow.
* @default []
*/
- values?: components["schemas"]["WorkflowReturnValueField"][];
+ values?: components["schemas"]["WorkflowReturnValueField"] | components["schemas"]["WorkflowReturnValueField"][];
/**
* type
* @default workflow_return
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index e00f91f3d77..13155539ba8 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -271,6 +271,16 @@ def test_workflow_return_invocation_contract():
assert not hasattr(output, "collection")
+def test_workflow_return_invocation_accepts_single_return_value():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
+
+ invocation = WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="sum", value=3))
+
+ output = invocation.invoke(build_context())
+
+ assert output.values == {"sum": 3}
+
+
def test_workflow_return_value_invocation_contract():
from invokeai.app.invocations.workflow_return import WorkflowReturnValueField, WorkflowReturnValueInvocation
diff --git a/tests/app/services/test_workflow_call_compatibility.py b/tests/app/services/test_workflow_call_compatibility.py
index 1b32d67c25a..942af51c800 100644
--- a/tests/app/services/test_workflow_call_compatibility.py
+++ b/tests/app/services/test_workflow_call_compatibility.py
@@ -140,6 +140,48 @@ def test_get_workflow_call_compatibility_returns_ok_for_simple_callable_workflow
assert compatibility.message is None
+def test_get_workflow_call_compatibility_allows_single_return_value_connected_directly() -> None:
+ workflow = _workflow_dump(
+ nodes=[
+ _invocation_node("sum", "add", {"a": {"value": 1}, "b": {"value": 2}}),
+ _invocation_node(
+ "return-value", "workflow_return_value", {"key": {"value": "sum"}, "value": {"value": None}}
+ ),
+ _invocation_node("return", "workflow_return", {"values": {"value": []}}),
+ ],
+ edges=[
+ {
+ "id": "edge-sum-return-value",
+ "type": "default",
+ "source": "sum",
+ "sourceHandle": "value",
+ "target": "return-value",
+ "targetHandle": "value",
+ },
+ {
+ "id": "edge-return-value-return",
+ "type": "default",
+ "source": "return-value",
+ "sourceHandle": "value",
+ "target": "return",
+ "targetHandle": "values",
+ },
+ ],
+ )
+
+ compatibility = get_workflow_call_compatibility(
+ workflow=workflow,
+ workflow_id="workflow-a",
+ services=_services(),
+ user_id="user-1",
+ maximum_children=1000,
+ )
+
+ assert compatibility.is_callable is True
+ assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
+ assert compatibility.message is None
+
+
def test_get_workflow_call_compatibility_reports_missing_workflow_return() -> None:
workflow = _workflow_dump(nodes=[_invocation_node("add", "add", {"a": {"value": 1}, "b": {"value": 2}})], edges=[])
@@ -178,6 +220,43 @@ def test_get_workflow_call_compatibility_reports_multiple_workflow_return_nodes(
assert compatibility.message == "The workflow must not contain more than one workflow_return node."
+def test_get_workflow_call_compatibility_does_not_report_present_malformed_workflow_return_as_missing() -> None:
+ workflow = _workflow_dump(
+ nodes=[
+ {
+ "id": "return",
+ "type": "invocation",
+ "position": {"x": 0, "y": 0},
+ "data": {
+ "type": "workflow_return",
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "isOpen": True,
+ "isIntermediate": False,
+ "useCache": True,
+ "dynamicInputTemplates": {},
+ "inputs": {"values": {"value": []}},
+ },
+ }
+ ],
+ edges=[],
+ )
+
+ compatibility = get_workflow_call_compatibility(
+ workflow=workflow,
+ workflow_id="workflow-a",
+ services=_services(),
+ user_id="user-1",
+ maximum_children=1000,
+ )
+
+ assert compatibility.is_callable is False
+ assert compatibility.reason is WorkflowCallCompatibilityReason.InvalidGraph
+ assert compatibility.message != "The workflow must contain exactly one workflow_return node."
+
+
def test_get_workflow_call_compatibility_reports_unsupported_connected_batch_input() -> None:
workflow = _workflow_dump(
nodes=[
diff --git a/tests/app/services/test_workflow_call_runtime.py b/tests/app/services/test_workflow_call_runtime.py
index 9a57204c208..9317305b93b 100644
--- a/tests/app/services/test_workflow_call_runtime.py
+++ b/tests/app/services/test_workflow_call_runtime.py
@@ -19,6 +19,10 @@ def test_workflow_call_queue_lifecycle_leaves_non_call_workflows_on_normal_execu
)
+def test_default_session_processor_uses_runner_workflow_call_lifecycle(monkeypatch) -> None:
+ workflow_call_tests.test_default_session_processor_uses_runner_workflow_call_lifecycle(monkeypatch)
+
+
def test_workflow_call_queue_lifecycle_resumes_parent_from_completed_child(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_queue_lifecycle_resumes_parent_from_completed_child(monkeypatch)
diff --git a/tests/app/services/workflow_call_test_utils.py b/tests/app/services/workflow_call_test_utils.py
index f6681b61d3d..889909bb918 100644
--- a/tests/app/services/workflow_call_test_utils.py
+++ b/tests/app/services/workflow_call_test_utils.py
@@ -1449,6 +1449,20 @@ def test_workflow_call_queue_lifecycle_leaves_non_call_workflows_on_normal_execu
assert events.errors == []
+def test_default_session_processor_uses_runner_workflow_call_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None:
+ session_queue = _DummySessionQueue()
+ runner, _events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
+
+ queue_item = SimpleNamespace(item_id=1, session_id="session-id")
+ calls: list[object] = []
+ runner.workflow_call_queue_lifecycle.run_queue_item = calls.append
+
+ processor.workflow_call_queue_lifecycle.run_queue_item(queue_item)
+
+ assert calls == [queue_item]
+
+
def test_workflow_call_queue_lifecycle_resumes_parent_from_completed_child(
monkeypatch: pytest.MonkeyPatch,
) -> None:
From 163f459693c1a0658f936817b6f662e503685788 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 15:59:25 -0500
Subject: [PATCH 094/100] Bugfixes
---
invokeai/app/invocations/workflow_return.py | 2 +-
.../store/util/validateConnection.test.ts | 79 +++++++++++++++++++
.../invocations/test_call_saved_workflows.py | 16 +++-
3 files changed, 94 insertions(+), 3 deletions(-)
diff --git a/invokeai/app/invocations/workflow_return.py b/invokeai/app/invocations/workflow_return.py
index 494931e06a7..497d050c8a7 100644
--- a/invokeai/app/invocations/workflow_return.py
+++ b/invokeai/app/invocations/workflow_return.py
@@ -86,7 +86,7 @@ class WorkflowReturnInvocation(BaseInvocation):
default=[],
description="The named values returned to a calling workflow.",
title="Values",
- ui_type=UIType._Collection,
+ input=Input.Connection,
)
def invoke(self, context: InvocationContext) -> WorkflowReturnOutput:
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
index 5cd6d1dc4fe..1eef0794436 100644
--- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts
@@ -159,6 +159,66 @@ const integerCollectionOutputTemplate: InvocationTemplate = {
classification: 'stable',
};
+const workflowReturnValueTemplate: InvocationTemplate = {
+ title: 'Workflow Return Value',
+ type: 'workflow_return_value',
+ version: '1.0.0',
+ tags: ['workflow', 'return', 'output'],
+ category: 'workflow',
+ description: 'Creates one named value for a callable workflow return.',
+ outputType: 'workflow_return_value_output',
+ inputs: {},
+ outputs: {
+ value: {
+ fieldKind: 'output',
+ name: 'value',
+ title: 'Return Value',
+ description: 'The named workflow return value.',
+ type: {
+ name: 'CollectionItemField',
+ cardinality: 'SINGLE',
+ batch: false,
+ },
+ ui_hidden: false,
+ ui_type: 'CollectionItemField',
+ },
+ },
+ useCache: false,
+ nodePack: 'invokeai',
+ classification: 'beta',
+};
+
+const workflowReturnTemplate: InvocationTemplate = {
+ title: 'Workflow Return',
+ type: 'workflow_return',
+ version: '1.0.0',
+ tags: ['workflow', 'return', 'output'],
+ category: 'workflow',
+ description: 'Defines the explicit named result returned by a callable workflow.',
+ outputType: 'workflow_return_output',
+ inputs: {
+ values: {
+ name: 'values',
+ title: 'Values',
+ required: false,
+ description: 'The named values returned to a calling workflow.',
+ fieldKind: 'input',
+ input: 'connection',
+ ui_hidden: false,
+ type: {
+ name: 'WorkflowReturnValueField',
+ cardinality: 'SINGLE_OR_COLLECTION',
+ batch: false,
+ },
+ default: undefined,
+ },
+ },
+ outputs: {},
+ useCache: false,
+ nodePack: 'invokeai',
+ classification: 'beta',
+};
+
const buildConnectorNode = (id: string) => ({
id,
type: 'connector' as const,
@@ -268,6 +328,25 @@ describe(validateConnection.name, () => {
expect(r).toEqual(null);
});
+ it('accepts a single workflow return value connected directly to workflow_return values', () => {
+ const sourceNode = buildNode(workflowReturnValueTemplate);
+ const targetNode = buildNode(workflowReturnTemplate);
+ const c = { source: sourceNode.id, sourceHandle: 'value', target: targetNode.id, targetHandle: 'values' };
+
+ const r = validateConnection(
+ c,
+ [sourceNode, targetNode],
+ [],
+ {
+ workflow_return_value: workflowReturnValueTemplate,
+ workflow_return: workflowReturnTemplate,
+ },
+ null
+ );
+
+ expect(r).toEqual(null);
+ });
+
describe('duplicate connections', () => {
const n1 = buildNode(add);
const n2 = buildNode(sub);
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index 13155539ba8..c60a845a3dd 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -281,6 +281,18 @@ def test_workflow_return_invocation_accepts_single_return_value():
assert output.values == {"sum": 3}
+def test_workflow_return_values_schema_preserves_single_or_list_cardinality():
+ from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation
+
+ values_schema = WorkflowReturnInvocation.model_json_schema()["properties"]["values"]
+
+ assert values_schema["anyOf"] == [
+ {"$ref": "#/$defs/WorkflowReturnValueField"},
+ {"items": {"$ref": "#/$defs/WorkflowReturnValueField"}, "type": "array"},
+ ]
+ assert values_schema.get("ui_type") != "CollectionField"
+
+
def test_workflow_return_value_invocation_contract():
from invokeai.app.invocations.workflow_return import WorkflowReturnValueField, WorkflowReturnValueInvocation
@@ -341,8 +353,8 @@ def test_workflow_return_invocation_schema_declares_named_values_contract():
assert "collection" not in schema["properties"]
values = schema["properties"]["values"]
- assert values["input"] == "any"
- assert values["ui_type"] == "CollectionField"
+ assert values["input"] == "connection"
+ assert "ui_type" not in values
get_schema = WorkflowReturnGetInvocation.model_json_schema()
get_values = get_schema["properties"]["values"]
From a13307f4df06958fd502e8e09ea594b76b4f1ba6 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 16:02:44 -0500
Subject: [PATCH 095/100] Fixed a persistence failure
---
invokeai/app/invocations/workflow_return.py | 2 +-
.../frontend/web/src/services/api/schema.ts | 3 ++-
.../invocations/test_call_saved_workflows.py | 26 +++++++++++++++++++
3 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/invokeai/app/invocations/workflow_return.py b/invokeai/app/invocations/workflow_return.py
index 497d050c8a7..9f517a26482 100644
--- a/invokeai/app/invocations/workflow_return.py
+++ b/invokeai/app/invocations/workflow_return.py
@@ -29,7 +29,7 @@ class WorkflowReturnValueField(BaseModel):
"""One named workflow return value."""
key: str = Field(description="The workflow return key.")
- value: Any = Field(description="The workflow return value.")
+ value: Any = Field(default=None, description="The workflow return value.")
@invocation_output("workflow_return_value_output")
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index dbc981e152e..abc48d66f8e 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -31739,8 +31739,9 @@ export type components = {
/**
* Value
* @description The workflow return value.
+ * @default null
*/
- value: unknown;
+ value?: unknown;
};
/**
* Workflow Return Value
diff --git a/tests/app/invocations/test_call_saved_workflows.py b/tests/app/invocations/test_call_saved_workflows.py
index c60a845a3dd..cb5cde5ca9a 100644
--- a/tests/app/invocations/test_call_saved_workflows.py
+++ b/tests/app/invocations/test_call_saved_workflows.py
@@ -303,6 +303,32 @@ def test_workflow_return_value_invocation_contract():
assert output.value == WorkflowReturnValueField(key="image", value={"image_name": "image-a"})
+def test_workflow_return_value_field_survives_exclude_none_session_roundtrip():
+ from invokeai.app.invocations.workflow_return import (
+ WorkflowReturnInvocation,
+ WorkflowReturnValueField,
+ WorkflowReturnValueOutput,
+ )
+ from invokeai.app.services.shared.graph import Graph, GraphExecutionState
+
+ graph = Graph()
+ graph.add_node(
+ WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="nullable", value=None))
+ )
+ session = GraphExecutionState(graph=graph)
+ session.execution_graph.add_node(
+ WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="nullable", value=None))
+ )
+ session.results["return-value-node"] = WorkflowReturnValueOutput(
+ value=WorkflowReturnValueField(key="nullable", value=None)
+ )
+
+ reloaded = GraphExecutionState.model_validate_json(session.model_dump_json(warnings=False, exclude_none=True))
+
+ assert reloaded.execution_graph.nodes["return-node"].values == WorkflowReturnValueField(key="nullable", value=None)
+ assert reloaded.results["return-value-node"].value == WorkflowReturnValueField(key="nullable", value=None)
+
+
def test_workflow_return_invocation_rejects_duplicate_keys():
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
From 821f3b9530921945ab043d7e23f209ee9f1be875 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 17:59:58 -0500
Subject: [PATCH 096/100] Fix saved workflow call field rehydration
---
.../Invocation/CallSavedWorkflowNode.tsx | 12 +++++++--
.../callSavedWorkflowFormUtils.test.ts | 25 +++++++++++++++++++
.../Invocation/callSavedWorkflowFormUtils.ts | 10 ++++++++
3 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
index 7840548cf1b..8db547a40d3 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/CallSavedWorkflowNode.tsx
@@ -18,7 +18,11 @@ import { memo, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
-import { getSavedWorkflowDynamicEdgeIdsToRemove, getSavedWorkflowDynamicFields } from './callSavedWorkflowFormUtils';
+import {
+ getSavedWorkflowDynamicEdgeIdsToRemove,
+ getSavedWorkflowDynamicFields,
+ shouldSyncSavedWorkflowDynamicFields,
+} from './callSavedWorkflowFormUtils';
const bodySx: SystemStyleObject = {
flexDirection: 'column',
@@ -56,6 +60,7 @@ const CallSavedWorkflowNode = ({ nodeId, isOpen }: Props) => {
skip: !workflowIdField.value,
});
+ const shouldSyncDynamicFields = shouldSyncSavedWorkflowDynamicFields({ workflowId: workflowIdField.value, workflow });
const dynamicFields = useMemo(() => getSavedWorkflowDynamicFields(workflow, templates), [templates, workflow]);
const edgeIdsToRemove = useMemo(
() =>
@@ -75,12 +80,15 @@ const CallSavedWorkflowNode = ({ nodeId, isOpen }: Props) => {
const lastSyncKeyRef = useRef(null);
useEffect(() => {
+ if (!shouldSyncDynamicFields) {
+ return;
+ }
if (lastSyncKeyRef.current === syncKey) {
return;
}
lastSyncKeyRef.current = syncKey;
dispatch(callSavedWorkflowDynamicFieldsChanged({ nodeId, fields: dynamicFields, edgeIdsToRemove }));
- }, [dispatch, dynamicFields, edgeIdsToRemove, nodeId, syncKey]);
+ }, [dispatch, dynamicFields, edgeIdsToRemove, nodeId, shouldSyncDynamicFields, syncKey]);
return (
<>
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
index 19ad20a486a..68290298bb5 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts
@@ -15,6 +15,7 @@ import {
getSavedWorkflowDynamicEdgeIdsToRemove,
getSavedWorkflowDynamicFields,
getSavedWorkflowFormFieldData,
+ shouldSyncSavedWorkflowDynamicFields,
} from './callSavedWorkflowFormUtils';
type WorkflowResponse =
@@ -88,6 +89,30 @@ const buildWorkflowResponse = (overrides?: {
}) as WorkflowResponse;
describe('callSavedWorkflowFormUtils', () => {
+ it('does not sync dynamic fields while a selected saved workflow is still loading', () => {
+ expect(
+ shouldSyncSavedWorkflowDynamicFields({
+ workflowId: 'workflow-1',
+ workflow: undefined,
+ })
+ ).toBe(false);
+ });
+
+ it('syncs dynamic fields when no workflow is selected or when the selected workflow is loaded', () => {
+ expect(
+ shouldSyncSavedWorkflowDynamicFields({
+ workflowId: '',
+ workflow: undefined,
+ })
+ ).toBe(true);
+ expect(
+ shouldSyncSavedWorkflowDynamicFields({
+ workflowId: 'workflow-1',
+ workflow: buildWorkflowResponse(),
+ })
+ ).toBe(true);
+ });
+
it('returns the stored form when it is non-empty and valid', () => {
const form = getDefaultForm();
const heading = buildHeading('Workflow Inputs');
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
index 1b55af05a58..fbc600f85c2 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts
@@ -248,6 +248,16 @@ export const getSavedWorkflowDynamicFields = (
return dynamicFields;
};
+export const shouldSyncSavedWorkflowDynamicFields = ({
+ workflowId,
+ workflow,
+}: {
+ workflowId: string | null | undefined;
+ workflow: WorkflowResponse | undefined;
+}): boolean => {
+ return !workflowId || Boolean(workflow);
+};
+
export const getSavedWorkflowDynamicEdgeIdsToRemove = ({
nodeId,
fields,
From 8b18ce0ddf55ca281a46e722604cf3150a96ded9 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 18:00:10 -0500
Subject: [PATCH 097/100] Preserve workflow call completion state on resume
---
.../events/nodeExecutionState.test.ts | 44 +++++++++++++++++
.../src/services/events/nodeExecutionState.ts | 27 +++++++++++
.../src/services/events/setEventListeners.tsx | 25 ++++------
.../services/test_workflow_call_runtime.py | 4 ++
.../app/services/workflow_call_test_utils.py | 47 +++++++++++++++++++
5 files changed, 132 insertions(+), 15 deletions(-)
diff --git a/invokeai/frontend/web/src/services/events/nodeExecutionState.test.ts b/invokeai/frontend/web/src/services/events/nodeExecutionState.test.ts
index e868f25da3d..98af94e219f 100644
--- a/invokeai/frontend/web/src/services/events/nodeExecutionState.test.ts
+++ b/invokeai/frontend/web/src/services/events/nodeExecutionState.test.ts
@@ -4,6 +4,7 @@ import type { S } from 'services/api/types';
import { describe, expect, it } from 'vitest';
import {
+ getResetNodeExecutionStatesOnQueueItemStarted,
getUpdatedNodeExecutionStateOnInvocationComplete,
getUpdatedNodeExecutionStateOnInvocationError,
getUpdatedNodeExecutionStateOnInvocationProgress,
@@ -108,6 +109,49 @@ const buildInvocationErrorEvent = (overrides: Partial
...overrides,
}) as S['InvocationErrorEvent'];
+describe(getResetNodeExecutionStatesOnQueueItemStarted.name, () => {
+ it('resets node execution states when a queue item starts for the first time', () => {
+ const updated = getResetNodeExecutionStatesOnQueueItemStarted(
+ {
+ 'node-1': buildNodeExecutionState({
+ status: zNodeStatus.enum.COMPLETED,
+ progress: 1,
+ outputs: [{ type: 'integer_output', value: 3 } as unknown as S['InvocationCompleteEvent']['result']],
+ }),
+ },
+ 1,
+ new Map()
+ );
+
+ expect(updated?.['node-1']).toEqual({
+ nodeId: 'node-1',
+ status: zNodeStatus.enum.PENDING,
+ progress: null,
+ progressImage: null,
+ outputs: [],
+ error: null,
+ });
+ });
+
+ it('does not reset node execution states when a workflow-call parent queue item resumes', () => {
+ const workflowReturnOutput = {
+ type: 'workflow_return_output',
+ values: { result: [3] },
+ } as unknown as S['InvocationCompleteEvent']['result'];
+ const existing = {
+ 'call-node': buildNodeExecutionState({
+ nodeId: 'call-node',
+ status: zNodeStatus.enum.COMPLETED,
+ outputs: [workflowReturnOutput],
+ }),
+ };
+
+ const updated = getResetNodeExecutionStatesOnQueueItemStarted(existing, 1, new Map([[1, new Set(['call-node'])]]));
+
+ expect(updated).toBeUndefined();
+ });
+});
+
describe(getUpdatedNodeExecutionStateOnInvocationStarted.name, () => {
it('creates an execution state when started arrives before initialization', () => {
const event = buildInvocationStartedEvent();
diff --git a/invokeai/frontend/web/src/services/events/nodeExecutionState.ts b/invokeai/frontend/web/src/services/events/nodeExecutionState.ts
index c15f0467772..bfb6f32e718 100644
--- a/invokeai/frontend/web/src/services/events/nodeExecutionState.ts
+++ b/invokeai/frontend/web/src/services/events/nodeExecutionState.ts
@@ -1,4 +1,5 @@
import { deepClone } from 'common/util/deepClone';
+import type { NodeExecutionStates } from 'features/nodes/store/types';
import type { NodeExecutionState } from 'features/nodes/types/invocation';
import { zNodeStatus } from 'features/nodes/types/invocation';
import type { S } from 'services/api/types';
@@ -15,6 +16,32 @@ const getInitialNodeExecutionState = (nodeId: string): NodeExecutionState => ({
error: null,
});
+export const getResetNodeExecutionStatesOnQueueItemStarted = (
+ nodeExecutionStates: NodeExecutionStates,
+ itemId: number,
+ completedInvocationKeysByItemId: CompletedInvocationKeysByItemId
+): NodeExecutionStates | undefined => {
+ if (completedInvocationKeysByItemId.has(itemId)) {
+ return;
+ }
+
+ const next: NodeExecutionStates = {};
+ for (const [nodeId, nodeExecutionState] of Object.entries(nodeExecutionStates)) {
+ if (!nodeExecutionState) {
+ next[nodeId] = nodeExecutionState;
+ continue;
+ }
+ const clone = deepClone(nodeExecutionState);
+ clone.status = zNodeStatus.enum.PENDING;
+ clone.error = null;
+ clone.progress = null;
+ clone.progressImage = null;
+ clone.outputs = [];
+ next[nodeId] = clone;
+ }
+ return next;
+};
+
export const getUpdatedNodeExecutionStateOnInvocationStarted = (
nodeExecutionState: NodeExecutionState | undefined,
data: S['InvocationStartedEvent'],
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
index c428823a2a0..49113f78d74 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
@@ -2,8 +2,7 @@ import { Flex, Text } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { socketConnected } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected';
import type { AppStore } from 'app/store/store';
-import { deepClone } from 'common/util/deepClone';
-import { forEach, isNil, round } from 'es-toolkit/compat';
+import { isNil, round } from 'es-toolkit/compat';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { allEntitiesDeleted, controlLayerRecalled } from 'features/controlLayers/store/canvasSlice';
import { canvasWorkflowIntegrationProcessingCompleted } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
@@ -28,7 +27,6 @@ import { getControlLayerState, getReferenceImageState } from 'features/controlLa
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
-import { zNodeStatus } from 'features/nodes/types/invocation';
import { modelSelected } from 'features/parameters/store/actions';
import ErrorToastDescription, { getTitle } from 'features/toast/ErrorToastDescription';
import { toast, toastApi } from 'features/toast/toast';
@@ -45,6 +43,7 @@ import {
shouldIgnoreFinishedQueueItemInvocationEvent,
} from 'services/events/invocationTracking';
import {
+ getResetNodeExecutionStatesOnQueueItemStarted,
getUpdatedNodeExecutionStateOnInvocationError,
getUpdatedNodeExecutionStateOnInvocationProgress,
getUpdatedNodeExecutionStateOnInvocationStarted,
@@ -512,18 +511,14 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
dispatch(queueApi.util.invalidateTags(tagsToInvalidate));
if (status === 'in_progress') {
- forEach($nodeExecutionStates.get(), (nes) => {
- if (!nes) {
- return;
- }
- const clone = deepClone(nes);
- clone.status = zNodeStatus.enum.PENDING;
- clone.error = null;
- clone.progress = null;
- clone.progressImage = null;
- clone.outputs = [];
- $nodeExecutionStates.setKey(clone.nodeId, clone);
- });
+ const nextNodeExecutionStates = getResetNodeExecutionStatesOnQueueItemStarted(
+ $nodeExecutionStates.get(),
+ item_id,
+ completedInvocationKeysByItemId
+ );
+ if (nextNodeExecutionStates) {
+ $nodeExecutionStates.set(nextNodeExecutionStates);
+ }
} else if (status === 'completed' || status === 'failed' || status === 'canceled') {
finishedQueueItemIds.set(item_id, true);
clearCompletedInvocationKeysForQueueItem(completedInvocationKeysByItemId, item_id);
diff --git a/tests/app/services/test_workflow_call_runtime.py b/tests/app/services/test_workflow_call_runtime.py
index 9317305b93b..1edaaaae007 100644
--- a/tests/app/services/test_workflow_call_runtime.py
+++ b/tests/app/services/test_workflow_call_runtime.py
@@ -59,6 +59,10 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(monkeypatch
workflow_call_tests.test_run_completes_call_saved_workflow_and_runs_downstream_nodes(monkeypatch)
+def test_run_completes_parent_queue_item_when_return_get_is_terminal(monkeypatch) -> None:
+ workflow_call_tests.test_run_completes_parent_queue_item_when_return_get_is_terminal(monkeypatch)
+
+
def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch) -> None:
workflow_call_tests.test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch)
diff --git a/tests/app/services/workflow_call_test_utils.py b/tests/app/services/workflow_call_test_utils.py
index 889909bb918..acbfdb9edd5 100644
--- a/tests/app/services/workflow_call_test_utils.py
+++ b/tests/app/services/workflow_call_test_utils.py
@@ -1692,6 +1692,53 @@ def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(
assert session_queue.resumed_item_ids == [1]
+def test_run_completes_parent_queue_item_when_return_get_is_terminal(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session_queue = _DummySessionQueue()
+ runner, events, _workflow_records = _build_workflow_runner(monkeypatch, session_queue=session_queue)
+ processor = DefaultSessionProcessor(session_runner=runner)
+
+ graph = Graph()
+ graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ graph.add_node(WorkflowReturnGetInvocation(id="get-return", key="result"))
+ graph.add_edge(create_edge("call-node", "values", "get-return", "values"))
+
+ session = GraphExecutionState(graph=graph)
+ queue_item = type(
+ "QueueItem",
+ (),
+ {
+ "item_id": 1,
+ "status": "in_progress",
+ "session": session,
+ "session_id": "session-id",
+ "user_id": "user-1",
+ "queue_id": "default",
+ "batch_id": "batch-1",
+ },
+ )()
+
+ _drain_workflow_call_queue(processor.session_runner.workflow_call_queue_lifecycle, session_queue, queue_item)
+
+ assert not session.is_waiting_on_workflow_call()
+ assert session.is_complete()
+ assert session_queue.get_queue_item(1).status == "completed"
+ assert session_queue.completed_item_ids == [100, 1]
+ assert session_queue.waiting_item_ids == [1]
+ assert session_queue.resumed_item_ids == [1]
+ assert [invocation.get_type() for _queue_item, invocation in events.started] == [
+ "call_saved_workflow",
+ "add",
+ "integer_collection",
+ "workflow_return_value",
+ "collect",
+ "workflow_return",
+ "workflow_return_get",
+ ]
+ assert events.errors == []
+
+
def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
runner, events, _workflow_records = _build_workflow_runner(monkeypatch)
From 11d3eb7d67baf3fdc50f86aacb832cc66dc6969a Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 21:04:03 -0500
Subject: [PATCH 098/100] Update queue status from socket events
---
.../web/src/services/api/endpoints/queue.ts | 4 +
.../services/events/queueStatusEvents.test.ts | 128 ++++++++++++++++++
.../src/services/events/queueStatusEvents.ts | 71 ++++++++++
.../src/services/events/setEventListeners.tsx | 6 +
4 files changed, 209 insertions(+)
create mode 100644 invokeai/frontend/web/src/services/events/queueStatusEvents.test.ts
create mode 100644 invokeai/frontend/web/src/services/events/queueStatusEvents.ts
diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts
index e2788406c11..70f4e6a65cf 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts
@@ -6,6 +6,7 @@ import type {
GetQueueItemIdsArgs,
GetQueueItemIdsResult,
} from 'services/api/types';
+import { getQueueStatusWithObservedEvents } from 'services/events/queueStatusEvents';
import stableHash from 'stable-hash';
import type { Param0 } from 'tsafe';
@@ -152,6 +153,9 @@ export const queueApi = api.injectEndpoints({
url: buildQueueUrl('status'),
method: 'GET',
}),
+ transformResponse: (
+ response: paths['/api/v1/queue/{queue_id}/status']['get']['responses']['200']['content']['application/json']
+ ) => getQueueStatusWithObservedEvents(response),
providesTags: ['SessionQueueStatus', 'FetchOnReconnect'],
}),
getBatchStatus: build.query<
diff --git a/invokeai/frontend/web/src/services/events/queueStatusEvents.test.ts b/invokeai/frontend/web/src/services/events/queueStatusEvents.test.ts
new file mode 100644
index 00000000000..9c7dab37c90
--- /dev/null
+++ b/invokeai/frontend/web/src/services/events/queueStatusEvents.test.ts
@@ -0,0 +1,128 @@
+import type { S } from 'services/api/types';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ getQueueStatusWithObservedEvents,
+ getUpdatedQueueStatusOnQueueItemStatusChanged,
+ resetObservedQueueStatusEventsForTests,
+} from './queueStatusEvents';
+
+const buildQueueStatus = (overrides: Partial = {}): S['SessionQueueAndProcessorStatus'] => ({
+ queue: {
+ queue_id: 'default',
+ item_id: 1,
+ batch_id: 'batch-1',
+ session_id: 'session-1',
+ pending: 0,
+ in_progress: 1,
+ waiting: 0,
+ completed: 0,
+ failed: 0,
+ canceled: 0,
+ total: 1,
+ ...overrides,
+ },
+ processor: {
+ is_started: true,
+ is_processing: true,
+ },
+});
+
+const buildQueueStatusChangedEvent = (
+ overrides: Partial = {}
+): S['QueueItemStatusChangedEvent'] =>
+ ({
+ item_id: 1,
+ status: 'completed',
+ status_sequence: 2,
+ queue_status: {
+ queue_id: 'default',
+ item_id: null,
+ batch_id: null,
+ session_id: null,
+ pending: 0,
+ in_progress: 0,
+ waiting: 0,
+ completed: 1,
+ failed: 0,
+ canceled: 0,
+ total: 1,
+ },
+ ...overrides,
+ }) as S['QueueItemStatusChangedEvent'];
+
+beforeEach(() => {
+ resetObservedQueueStatusEventsForTests();
+});
+
+describe(getUpdatedQueueStatusOnQueueItemStatusChanged.name, () => {
+ it('uses the queue status from the socket event while preserving processor status', () => {
+ const current = buildQueueStatus();
+ const event = buildQueueStatusChangedEvent();
+
+ expect(getUpdatedQueueStatusOnQueueItemStatusChanged(current, event)).toEqual({
+ queue: event.queue_status,
+ processor: current.processor,
+ });
+ });
+});
+
+describe(getQueueStatusWithObservedEvents.name, () => {
+ it('does not let a stale current-item queue status response regress a terminal socket event', () => {
+ const current = buildQueueStatus();
+ const completedEvent = buildQueueStatusChangedEvent();
+ getUpdatedQueueStatusOnQueueItemStatusChanged(current, completedEvent);
+
+ const staleResponse = buildQueueStatus({ item_id: 1, in_progress: 1, completed: 0 });
+
+ expect(getQueueStatusWithObservedEvents(staleResponse)).toEqual({
+ ...staleResponse,
+ queue: completedEvent.queue_status,
+ });
+ });
+
+ it('accepts a later status event for the same item when its status sequence increases', () => {
+ const current = buildQueueStatus();
+ getUpdatedQueueStatusOnQueueItemStatusChanged(current, buildQueueStatusChangedEvent());
+ const retryEvent = buildQueueStatusChangedEvent({
+ status: 'in_progress',
+ status_sequence: 3,
+ queue_status: {
+ queue_id: 'default',
+ item_id: 1,
+ batch_id: 'batch-1',
+ session_id: 'session-1',
+ pending: 0,
+ in_progress: 1,
+ waiting: 0,
+ completed: 1,
+ failed: 0,
+ canceled: 0,
+ total: 2,
+ },
+ });
+
+ getUpdatedQueueStatusOnQueueItemStatusChanged(current, retryEvent);
+ const latestResponse = buildQueueStatus({ item_id: 1, in_progress: 1, completed: 1, total: 2 });
+
+ expect(getQueueStatusWithObservedEvents(latestResponse)).toBe(latestResponse);
+ });
+
+ it('ignores an older status event for an item with a newer terminal event', () => {
+ const current = buildQueueStatus();
+ const completedEvent = buildQueueStatusChangedEvent({ status_sequence: 2 });
+ getUpdatedQueueStatusOnQueueItemStatusChanged(current, completedEvent);
+ const staleInProgressEvent = buildQueueStatusChangedEvent({
+ status: 'in_progress',
+ status_sequence: 1,
+ queue_status: buildQueueStatus({ item_id: 1, in_progress: 1, completed: 0 }).queue,
+ });
+
+ getUpdatedQueueStatusOnQueueItemStatusChanged(current, staleInProgressEvent);
+
+ expect(getQueueStatusWithObservedEvents(buildQueueStatus({ item_id: 1 }))).toEqual({
+ ...current,
+ queue: completedEvent.queue_status,
+ });
+ });
+});
diff --git a/invokeai/frontend/web/src/services/events/queueStatusEvents.ts b/invokeai/frontend/web/src/services/events/queueStatusEvents.ts
new file mode 100644
index 00000000000..01b635a3ad3
--- /dev/null
+++ b/invokeai/frontend/web/src/services/events/queueStatusEvents.ts
@@ -0,0 +1,71 @@
+import type { S } from 'services/api/types';
+
+type QueueStatusResponse = S['SessionQueueAndProcessorStatus'];
+type QueueItemStatusChangedEvent = S['QueueItemStatusChangedEvent'];
+
+const TERMINAL_QUEUE_ITEM_STATUSES = new Set([
+ 'completed',
+ 'failed',
+ 'canceled',
+]);
+
+let latestObservedQueueStatus: S['SessionQueueStatus'] | null = null;
+const observedItemStatusSequences = new Map();
+const observedTerminalItemIds = new Set();
+
+const shouldAcceptQueueItemStatusEvent = (event: QueueItemStatusChangedEvent): boolean => {
+ const statusSequence = event.status_sequence ?? undefined;
+ if (statusSequence === undefined) {
+ return true;
+ }
+
+ const previousSequence = observedItemStatusSequences.get(event.item_id);
+ return previousSequence === undefined || statusSequence >= previousSequence;
+};
+
+const recordQueueItemStatusChangedEvent = (event: QueueItemStatusChangedEvent): void => {
+ if (!shouldAcceptQueueItemStatusEvent(event)) {
+ return;
+ }
+
+ if (event.status_sequence !== null && event.status_sequence !== undefined) {
+ observedItemStatusSequences.set(event.item_id, event.status_sequence);
+ }
+
+ if (TERMINAL_QUEUE_ITEM_STATUSES.has(event.status)) {
+ observedTerminalItemIds.add(event.item_id);
+ } else {
+ observedTerminalItemIds.delete(event.item_id);
+ }
+
+ latestObservedQueueStatus = event.queue_status;
+};
+
+export const getQueueStatusWithObservedEvents = (queueStatus: QueueStatusResponse): QueueStatusResponse => {
+ const currentItemId = queueStatus.queue.item_id;
+ if (latestObservedQueueStatus && currentItemId !== null && observedTerminalItemIds.has(currentItemId)) {
+ return {
+ ...queueStatus,
+ queue: latestObservedQueueStatus,
+ };
+ }
+
+ return queueStatus;
+};
+
+export const getUpdatedQueueStatusOnQueueItemStatusChanged = (
+ queueStatus: QueueStatusResponse,
+ event: QueueItemStatusChangedEvent
+): QueueStatusResponse => {
+ recordQueueItemStatusChangedEvent(event);
+ return {
+ ...queueStatus,
+ queue: event.queue_status,
+ };
+};
+
+export const resetObservedQueueStatusEventsForTests = (): void => {
+ latestObservedQueueStatus = null;
+ observedItemStatusSequences.clear();
+ observedTerminalItemIds.clear();
+};
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
index 49113f78d74..7943f459625 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
@@ -50,6 +50,7 @@ import {
} from 'services/events/nodeExecutionState';
import { buildOnInvocationComplete } from 'services/events/onInvocationComplete';
import { buildOnModelInstallError, DiscordLink, GitHubIssuesLink } from 'services/events/onModelInstallError';
+import { getUpdatedQueueStatusOnQueueItemStatusChanged } from 'services/events/queueStatusEvents';
import type { ClientToServerEvents, ServerToClientEvents } from 'services/events/types';
import type { Socket } from 'socket.io-client';
import type { JsonObject } from 'type-fest';
@@ -491,6 +492,11 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
})
);
}
+ dispatch(
+ queueApi.util.updateQueryData('getQueueStatus', undefined, (draft) =>
+ getUpdatedQueueStatusOnQueueItemStatusChanged(draft, data)
+ )
+ );
// Invalidate caches for things we cannot easily update
// Invalidate SessionQueueStatus to refetch with user-specific counts
From b12746ed99d3541bee1aa6f430579ccbe9bf4585 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Wed, 29 Apr 2026 21:38:50 -0500
Subject: [PATCH 099/100] Updated tests to match the current workflow-return
contract
---
tests/app/routers/test_multiuser_authorization.py | 4 ++++
tests/test_graph_execution_state.py | 14 +++++++++-----
2 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py
index fabbc46ff7e..502d83f0df6 100644
--- a/tests/app/routers/test_multiuser_authorization.py
+++ b/tests/app/routers/test_multiuser_authorization.py
@@ -1576,11 +1576,15 @@ def test_connect_accepted_without_token_in_single_user_mode(self, socketio: Any,
import asyncio
mock_invoker.services.configuration.multiuser = False
+ socketio._sio.enter_room = AsyncMock()
result = asyncio.run(socketio._handle_connect("sid-single-1", environ={}, auth=None))
assert result is True
assert socketio._socket_users["sid-single-1"]["user_id"] == "system"
assert socketio._socket_users["sid-single-1"]["is_admin"] is True
+ socketio._sio.enter_room.assert_any_call("sid-single-1", "user:system")
+ socketio._sio.enter_room.assert_any_call("sid-single-1", "workflows:shared")
+ socketio._sio.enter_room.assert_any_call("sid-single-1", "admin")
def test_connect_accepted_with_valid_token_in_multiuser_mode(
self,
diff --git a/tests/test_graph_execution_state.py b/tests/test_graph_execution_state.py
index 58f5d3e7a9c..63a3adf2e3b 100644
--- a/tests/test_graph_execution_state.py
+++ b/tests/test_graph_execution_state.py
@@ -287,7 +287,7 @@ def test_graph_attach_waiting_workflow_call_child_sessions_tracks_fan_out_metada
assert child_b.workflow_call_parent is not None
-def test_graph_record_waiting_workflow_call_child_completion_aggregates_collections():
+def test_graph_record_waiting_workflow_call_child_completion_aggregates_named_values():
parent = GraphExecutionState(graph=Graph())
parent.execution_graph.add_node(AddInvocation(id="prepared-parent", a=1, b=2))
parent.prepared_source_mapping["prepared-parent"] = "source-parent"
@@ -299,13 +299,17 @@ def test_graph_record_waiting_workflow_call_child_completion_aggregates_collecti
parent.begin_waiting_on_workflow_call(frame)
parent.attach_waiting_workflow_call_child_sessions([child_a, child_b])
- is_complete, aggregated_collection = parent.record_waiting_workflow_call_child_completion(101, [1, 2])
+ is_complete, aggregated_values = parent.record_waiting_workflow_call_child_completion(
+ 101, {"sum": 3, "images": "image-a"}
+ )
assert is_complete is False
- assert aggregated_collection == [1, 2]
+ assert aggregated_values == {"sum": [3], "images": ["image-a"]}
- is_complete, aggregated_collection = parent.record_waiting_workflow_call_child_completion(102, [3])
+ is_complete, aggregated_values = parent.record_waiting_workflow_call_child_completion(
+ 102, {"sum": 7, "images": "image-b"}
+ )
assert is_complete is True
- assert aggregated_collection == [1, 2, 3]
+ assert aggregated_values == {"sum": [3, 7], "images": ["image-a", "image-b"]}
assert parent.waiting_workflow_call_execution is not None
assert parent.waiting_workflow_call_execution.completed_child_item_ids == [101, 102]
From e2e2fbd863fcf5154a9025b59929f23b16eeead9 Mon Sep 17 00:00:00 2001
From: JPPhoto
Date: Thu, 30 Apr 2026 18:02:48 -0500
Subject: [PATCH 100/100] Fix workflow child queue status isolation
---
.../session_queue/session_queue_sqlite.py | 2 +-
...st_session_queue_status_event_isolation.py | 73 +++++++++++++++++++
2 files changed, 74 insertions(+), 1 deletion(-)
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index d8f40c20a84..d4f64de56ba 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -903,7 +903,7 @@ def enqueue_workflow_call_child(
queue_item = self.get_queue_item(item_id)
batch_status = self.get_batch_status(queue_id=queue_item.queue_id, batch_id=queue_item.batch_id)
- queue_status = self.get_queue_status(queue_id=queue_item.queue_id)
+ queue_status = self.get_queue_status(queue_id=queue_item.queue_id, acting_user_id=queue_item.user_id)
self.__invoker.services.events.emit_queue_item_status_changed(queue_item, batch_status, queue_status)
return queue_item
diff --git a/tests/app/services/session_queue/test_session_queue_status_event_isolation.py b/tests/app/services/session_queue/test_session_queue_status_event_isolation.py
index af7fdd89313..ecef76f1eb3 100644
--- a/tests/app/services/session_queue/test_session_queue_status_event_isolation.py
+++ b/tests/app/services/session_queue/test_session_queue_status_event_isolation.py
@@ -11,6 +11,7 @@
import pytest
+from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
@@ -47,6 +48,48 @@ def _insert_queue_item(session_queue: SqliteSessionQueue, user_id: str) -> int:
return cursor.lastrowid # type: ignore[return-value]
+def _insert_waiting_workflow_call_parent(
+ session_queue: SqliteSessionQueue, user_id: str
+) -> tuple[int, GraphExecutionState]:
+ parent_graph = Graph()
+ parent_graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
+ parent_session = GraphExecutionState(graph=parent_graph)
+ invocation = parent_session.next()
+ assert isinstance(invocation, CallSavedWorkflowInvocation)
+
+ frame = parent_session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
+ child_session = parent_session.create_child_workflow_execution_state(Graph(), frame)
+ parent_session.begin_waiting_on_workflow_call(frame)
+ parent_session.attach_waiting_workflow_call_child_session(child_session)
+
+ batch_id = str(uuid.uuid4())
+ with session_queue._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO session_queue (
+ queue_id, session, session_id, batch_id, field_values,
+ priority, workflow, origin, destination, retried_from_item_id, user_id, status
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "default",
+ parent_session.model_dump_json(warnings=False, exclude_none=True),
+ parent_session.id,
+ batch_id,
+ None,
+ 0,
+ None,
+ None,
+ None,
+ None,
+ user_id,
+ "waiting",
+ ),
+ )
+ return cursor.lastrowid, child_session # type: ignore[return-value]
+
+
def _last_status_event_for_item(event_bus: TestEventService, item_id: int) -> QueueItemStatusChangedEvent:
matches = [e for e in event_bus.events if isinstance(e, QueueItemStatusChangedEvent) and e.item_id == item_id]
assert matches, f"No QueueItemStatusChangedEvent found for item {item_id}"
@@ -208,3 +251,33 @@ def test_event_preserves_identifiers_when_current_item_is_the_changed_item(
assert a_event.queue_status.item_id == a_item_id
assert a_event.queue_status.session_id == in_progress.session_id
assert a_event.queue_status.batch_id == in_progress.batch_id
+
+
+def test_workflow_call_child_enqueue_event_redacts_other_users_current_item_identifiers(
+ session_queue: SqliteSessionQueue, mock_invoker: Invoker
+) -> None:
+ """The child enqueue path emits QueueItemStatusChangedEvent without going through
+ _set_queue_item_status, so it must apply the same per-owner current-item redaction."""
+ user_a = "user-a"
+ user_b = "user-b"
+
+ b_item_id = _insert_queue_item(session_queue, user_id=user_b)
+ parent_item_id, child_session = _insert_waiting_workflow_call_parent(session_queue, user_id=user_a)
+
+ in_progress = session_queue.dequeue()
+ assert in_progress is not None and in_progress.item_id == b_item_id
+ assert in_progress.user_id == user_b
+
+ event_bus: TestEventService = mock_invoker.services.events
+ event_bus.events.clear()
+
+ parent_queue_item = session_queue.get_queue_item(parent_item_id)
+ child_queue_item = session_queue.enqueue_workflow_call_child(parent_queue_item, child_session)
+
+ child_event = _last_status_event_for_item(event_bus, child_queue_item.item_id)
+ assert child_event.user_id == user_a
+ assert child_event.queue_status.item_id is None, "must not leak other user's current item_id"
+ assert child_event.queue_status.session_id is None, "must not leak other user's current session_id"
+ assert child_event.queue_status.batch_id is None, "must not leak other user's current batch_id"
+ assert child_event.queue_status.in_progress == 1
+ assert child_event.queue_status.waiting == 1