diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx index 5d5d0eb28b5..7cd6c0fd3ef 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx @@ -49,7 +49,9 @@ If a required component is missing, **Invoke** is disabled and its tooltip names -Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. +Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. To run a batch ahead of the work you already have queued, press **Ctrl+Shift+Enter** (**Cmd+Shift+Enter** on macOS) or choose **Invoke (Front of Queue)** from the command palette. When several people share a server, the default round-robin queue still alternates between users, so the batch only moves ahead of your own jobs; a server set to first-in, first-out (`session_queue_mode: FIFO`) runs it ahead of everyone's pending jobs. + +The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. A retried job's results go where the original was headed while the tab that sent it is still open: back to its place in the Canvas staging area, or to the Gallery board it was sent to. This also holds when you retry several failed items of one batch one after another. A retried Canvas job is staged in a tab where its project is open; if that project is open nowhere, its result does not appear. Once that tab is gone, for example after a reload, or once its Canvas was replaced with a new document, Canvas results are staged once, at the current bounding box, by a tab with the project open, and Gallery results appear in the Gallery, on **Uncategorized** unless the job itself saved them to a board. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. ## Routing images to the Gallery or the Canvas diff --git a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx index 8f509325ccc..cf5b666bb01 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx @@ -148,9 +148,9 @@ Drag items onto a board in the board list; if the list is hidden, it opens while ### Deleting media -Press **Delete** (or **Backspace**), choose **Delete Image**, **Delete Video** or **Delete Selection** from the menu, or use the delete button in the selection bar. Deleting permanently removes the items from every board and from disk, and cannot be undone. A confirmation asks first. Tick **Don't ask me again** and confirm to stop asking (cancelling leaves it on); you can turn it back on, or off, with **Confirm image deletion** in Settings under Gallery. +Press **Delete** (or **Backspace**), choose **Delete Image**, **Delete Video** or **Delete Selection** from the menu, or use the delete button in the selection bar. Deleting permanently removes the items from every board and from disk, and cannot be undone. A confirmation asks first. Tick **Don't ask me again** and confirm to stop asking for ordinary deletions (cancelling leaves it on); you can turn it back on, or off, with **Confirm image deletion** in Settings under Gallery. -Deleting does not check whether an image is still used elsewhere, for example on a canvas or as a reference image, so check before you delete media you are still working with. If deleting is interrupted, InvokeAI cleans up any leftover files the next time it starts. +When the open project still uses something you are deleting, for example as a canvas layer, a reference image, an upscale or video input, or in a workflow, the confirmation always appears and lists where, even if you turned confirmations off. Only the open project's current work is checked: other projects, undo history and canvas snapshots are not, so check before you delete media you still need there. If deleting is interrupted, InvokeAI cleans up any leftover files the next time it starts. ## Related pages diff --git a/docs/src/content/docs/Users Guide/08.Workflows/01.introduction.mdx b/docs/src/content/docs/Users Guide/08.Workflows/01.introduction.mdx index 336cb380931..ae326de90a9 100644 --- a/docs/src/content/docs/Users Guide/08.Workflows/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/08.Workflows/01.introduction.mdx @@ -54,7 +54,7 @@ Hover over a numbered circle, or tap it on a touchscreen, for a short descriptio |---|---|---| | 1 | **Workflow panel** | The workflow's **form**: its most important settings as simple controls, like a Generate panel for this workflow. | | 2 | **View** / **Edit** | **View** uses the form. **Edit** lets you design the form, edit the workflow's details (name, description, author, tags, notes), and inspect the selected node. | -| 3 | **Editor toolbar** | Tools for panning, box and lasso selection, and erasing nodes or connections; **Zoom in**, **Zoom out** and **Fit view**; **Download workflow as PNG**; and **Node opacity**. | +| 3 | **Editor toolbar** | Tools for panning, box and lasso selection, and erasing nodes or connections; **Zoom in**, **Zoom out** and **Fit view**; **Arrange nodes**, which lays out the whole graph, or just the selected nodes in place, as one undoable step; **Download workflow as PNG**; and **Node opacity**. | | 4 | **Workflow name** | The active workflow. Click it to open the **Workflows** library. | | 5 | **Workflow actions** | **Add node** (**+**), **Save to library…**, the save status, settings, and the **⋯** menu with **Workflow details…**, **Rename workflow…**, **Import workflow JSON…**, **Export workflow JSON**, **New workflow** and more. | | 6 | **The graph** | The workflow's nodes and connections. Drag nodes by their title bar; scroll to zoom; drag empty space to pan. | diff --git a/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx index c9894b15405..721e6f544de 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx @@ -68,7 +68,7 @@ INVOKEAI_SESSION_QUEUE_MODE=FIFO `session_queue_mode` only applies in multiuser mode. In single-user mode the queue is always FIFO regardless of this setting, since all jobs belong to the same account. ::: -Round-robin fairness is determined from when each user's jobs were last started. Retained terminal queue history (see [`max_queue_history`](#configuration-reference)) does not slow dequeue scheduling — each dequeue's cost scales with the number of users who currently have pending jobs, not with the size of the history. +Round-robin fairness is determined from when each user's jobs were last started. Retained terminal queue history (see [`max_queue_history`](#configuration-reference)) does not slow dequeue scheduling — each dequeue's cost scales with the number of users who currently have pending jobs, not with the size of the history. Administrators can change `max_queue_history` without editing the file: open **Settings → Server → Queue history**, where a blank value keeps every finished item and `0` prunes them all at the next start. ### First Administrator Account diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index c64f32623b1..0c1471a1d26 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -29,6 +29,8 @@ EnqueueReceiptLimitError, ItemIdsResult, PruneResult, + QueueItemDeliveryRequest, + QueueItemDeliveryResult, RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, @@ -882,6 +884,40 @@ def cancel_queue_item( raise HTTPException(status_code=500, detail=f"Unexpected error while canceling queue item: {e}") +@session_queue_router.post( + "/{queue_id}/i/{item_id}/delivery", + operation_id="claim_queue_item_delivery", + response_model=QueueItemDeliveryResult, +) +def claim_queue_item_delivery( + current_user: CurrentUserOrDefault, + queue_id: str = Path(description="The queue id to perform this operation on"), + item_id: int = Path(description="The queue item whose results are delivered"), + request: QueueItemDeliveryRequest = Body(description="The delivery change"), +) -> QueueItemDeliveryResult: + """Claims, confirms or releases staging a queue item's results, so that only one of its owner's browsers stages + them. A claim is held for a while unless confirmed, released or renewed.""" + session_queue = ApiDependencies.invoker.services.session_queue + try: + # Waiting browsers ask often: authorize by the item's owner alone, without reading its session. + owner = session_queue.get_queue_item_owner(item_id) + if owner.queue_id != queue_id: + raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + if owner.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException(status_code=403, detail=f"You do not have permission to mutate queue item {item_id}") + if request.action == "claim": + return session_queue.claim_queue_item_delivery(item_id, request.claimant) + if request.action == "confirm": + return session_queue.confirm_queue_item_delivery(item_id) + return session_queue.release_queue_item_delivery(item_id, request.claimant) + except SessionQueueItemNotFoundError: + raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Unexpected error while changing queue item delivery: {e}") + + @session_queue_router.get( "/{queue_id}/counts_by_destination", operation_id="counts_by_destination", diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 4ddbf95408e..9446216f8e9 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -920,16 +920,24 @@ async def _emit_bulk_queue_item_event( """ item_ids_by_user: dict[str, list[int]] = getattr(event_data, item_ids_by_user_field) admin_sids = self._admin_sids() + + def scoped(item_ids: list[int], user_ids: list[str]) -> dict[str, Any]: + update: dict[str, Any] = { + item_ids_field: item_ids, + "user_ids": user_ids, + item_ids_by_user_field: dict.fromkeys(user_ids, item_ids), + } + if isinstance(event_data, QueueItemsRetriedEvent): + # A retry's new item ids are as private as the retried ids they are paired with. + owned_item_ids = set(item_ids) + update["retried_items"] = [ + item for item in event_data.retried_items if item.source_item_id in owned_item_ids + ] + return update + for user_id in event_data.user_ids: user_room = f"user:{user_id}" - owner_item_ids = item_ids_by_user.get(user_id, []) - owner_event_data = event_data.model_copy( - update={ - item_ids_field: owner_item_ids, - "user_ids": [user_id], - item_ids_by_user_field: {user_id: owner_item_ids}, - } - ) + owner_event_data = event_data.model_copy(update=scoped(item_ids_by_user.get(user_id, []), [user_id])) await self._sio.emit( event=event_name, data=owner_event_data.model_dump(mode="json"), @@ -938,7 +946,7 @@ async def _emit_bulk_queue_item_event( ) await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin") - sanitized = event_data.model_copy(update={item_ids_field: [], "user_ids": [], item_ids_by_user_field: {}}) + sanitized = event_data.model_copy(update=scoped([], [])) await self._sio.emit( event=event_name, data=sanitized.model_dump(mode="json"), diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py index 1f5ff2712ea..c1605cc9aab 100644 --- a/invokeai/app/services/events/events_common.py +++ b/invokeai/app/services/events/events_common.py @@ -13,6 +13,7 @@ QUEUE_ITEM_STATUS, BatchStatus, EnqueueBatchResult, + RetriedQueueItem, RetryItemsResult, SessionQueueItem, SessionQueueStatus, @@ -384,6 +385,9 @@ class QueueItemsRetriedEvent(QueueEventBase): retried_item_ids_by_user: dict[str, list[int]] = Field( description="The retried root queue item IDs keyed by owner user ID." ) + retried_items: list[RetriedQueueItem] = Field( + default_factory=list, description="Each retried root queue item with the queue item that re-runs it" + ) @classmethod def build( @@ -394,6 +398,7 @@ def build( retried_item_ids=retry_result.retried_item_ids, user_ids=user_ids, retried_item_ids_by_user=retried_item_ids_by_user, + retried_items=retry_result.retried_items, ) diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 20aa4b278aa..e95d0cc9125 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -20,6 +20,8 @@ ItemIdsResult, NodeFieldValue, PruneResult, + QueueItemDeliveryResult, + QueueItemOwner, RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, @@ -292,6 +294,27 @@ def get_queue_item_for_api(self, item_id: int) -> SessionQueueItem: """Gets a queue item in the response projection used by API reads.""" return self.get_queue_item(item_id=item_id) + @abstractmethod + def get_queue_item_owner(self, item_id: int) -> QueueItemOwner: + """Gets a queue item's queue and owner without reading its session, for authorizing frequent requests.""" + pass + + @abstractmethod + def claim_queue_item_delivery(self, item_id: int, claimant: str) -> QueueItemDeliveryResult: + """Claims delivering a queue item's results for a claimant: `claimed` unless they were delivered or another + claimant's unexpired claim holds them.""" + pass + + @abstractmethod + def confirm_queue_item_delivery(self, item_id: int) -> QueueItemDeliveryResult: + """Marks a queue item's results delivered, whoever claimed them.""" + pass + + @abstractmethod + def release_queue_item_delivery(self, item_id: int, claimant: str) -> QueueItemDeliveryResult: + """Drops a claimant's claim of a queue item's undelivered results, if it holds one.""" + pass + @abstractmethod def get_queue_item_workflow_json(self, item_id: int) -> str | None: """Gets only the workflow metadata JSON for a queue item, if present.""" diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index ef749015f53..31fc0ada27c 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -2,7 +2,7 @@ import json from collections.abc import Mapping from itertools import chain, product -from typing import Any, Generator, Literal, Optional, TypeAlias, Union +from typing import Any, Generator, Literal, NamedTuple, Optional, TypeAlias, Union from pydantic import ( AliasChoices, @@ -536,9 +536,51 @@ def validate_result(self) -> "EnqueueBatchReceipt": return self +class RetriedQueueItem(BaseModel): + source_item_id: int = Field(description="The ID of the retried root queue item") + item_id: int = Field(description="The ID of the queue item that re-runs it") + + class RetryItemsResult(BaseModel): queue_id: str = Field(description="The ID of the queue") retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried") + retried_items: list[RetriedQueueItem] = Field( + default_factory=list, description="Each retried root queue item with the queue item that re-runs it" + ) + + +QueueItemDeliveryAction: TypeAlias = Literal["claim", "confirm", "release"] +QueueItemDeliveryStatus: TypeAlias = Literal["claimed", "delivered", "held", "released"] + + +class QueueItemDeliveryRequest(BaseModel): + """A change to who stages a queue item's results, so that only one browser does.""" + + action: QueueItemDeliveryAction = Field( + description="claim: take delivering the results; confirm: mark them delivered; release: give up a claim" + ) + claimant: str = Field( + min_length=1, max_length=128, description="An id the client chooses for the browser tab or run claiming" + ) + + +class QueueItemOwner(NamedTuple): + """The queue a queue item is in and the account that owns it.""" + + queue_id: str + user_id: str + + +class QueueItemDeliveryResult(BaseModel): + """Who stages a queue item's results after a delivery request.""" + + status: QueueItemDeliveryStatus = Field( + description="claimed: the claimant may deliver; delivered: the results were delivered; " + "held: another claimant is delivering them; released: the claimant holds no claim" + ) + retry_after_ms: Optional[int] = Field( + default=None, description="When held, how long until the other claim lapses, in milliseconds" + ) class ClearResult(BaseModel): diff --git a/invokeai/app/services/session_queue/session_queue_default.py b/invokeai/app/services/session_queue/session_queue_default.py index 65c3b6fa006..87e30f11d64 100644 --- a/invokeai/app/services/session_queue/session_queue_default.py +++ b/invokeai/app/services/session_queue/session_queue_default.py @@ -1,7 +1,8 @@ import asyncio import hashlib import json -from datetime import timedelta +import math +from datetime import datetime, timedelta, timezone from typing import Any, Literal, Optional from pydantic_core import to_jsonable_python @@ -30,6 +31,9 @@ ItemIdsResult, NodeFieldValue, PruneResult, + QueueItemDeliveryResult, + QueueItemOwner, + RetriedQueueItem, RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, @@ -46,7 +50,8 @@ from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries import Queries from invokeai.app.services.shared.database.queries.locks import DatabaseLock -from invokeai.app.services.shared.database.queries.session_queue import Scope, SettledReceipt +from invokeai.app.services.shared.database.queries.session_queue import ITEM_COLUMNS, Delivery, Scope, SettledReceipt +from invokeai.app.services.shared.database.types import timestamp_text from invokeai.app.services.shared.execution_state_migration import dump_execution_state from invokeai.app.services.shared.graph import Graph, GraphExecutionState from invokeai.app.services.shared.pagination import CursorPaginatedResults, SQLiteDirection @@ -66,6 +71,10 @@ _FINISHED = ("completed", "failed", "canceled") +# How long a browser's claim to stage a queue item's results holds it from other browsers, unless renewed: long +# enough to stage them, short enough that a browser that went away holds them up only briefly. +QUEUE_ITEM_DELIVERY_CLAIM_TTL = timedelta(seconds=120) + # Every write that adds items takes the queue's admission lock, so that two of them neither both fit into its last # free places nor both write one enqueue receipt, and shares the media protection: the items' sessions make the media # they name active, which the intermediates cleanup must not delete between its check and its delete. @@ -78,6 +87,19 @@ def _session_json(session: GraphExecutionState) -> str: return json.dumps(dump_execution_state(session), default=to_jsonable_python) +def _held(delivery: Delivery, now: datetime) -> QueueItemDeliveryResult: + """Why a claim was refused: the results were delivered, or another claim holds them until it lapses.""" + if delivery.delivered_at is not None: + return QueueItemDeliveryResult(status="delivered") + retry_after_ms = 1 + # Without a claim it was released after the refusal: the claimant may try again at once. + if delivery.claimant is not None and delivery.claimed_at is not None: + lapses_at = datetime.fromisoformat(delivery.claimed_at).replace(tzinfo=timezone.utc) + remaining = lapses_at + QUEUE_ITEM_DELIVERY_CLAIM_TTL - now + retry_after_ms = max(1, math.ceil(remaining / timedelta(milliseconds=1))) + return QueueItemDeliveryResult(status="held", retry_after_ms=retry_after_ms) + + def _decode_enqueue_receipt(batch_id: str, requested: int, enqueued: int, item_ids_json: str) -> EnqueueBatchReceipt: item_ids = json.loads(item_ids_json) if not isinstance(item_ids, list) or not all(isinstance(item_id, int) for item_id in item_ids): @@ -720,6 +742,42 @@ def get_queue_item_for_api(self, item_id: int) -> SessionQueueItem: def get_queue_item_workflow_json(self, item_id: int) -> str | None: return self._queries.session_queue.workflow(item_id) + def get_queue_item_owner(self, item_id: int) -> QueueItemOwner: + owner = self._queries.session_queue.owner(item_id) + if owner is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + return QueueItemOwner(queue_id=owner.queue_id, user_id=owner.user_id) + + def claim_queue_item_delivery(self, item_id: int, claimant: str) -> QueueItemDeliveryResult: + def claim(q: Queries) -> QueueItemDeliveryResult: + now = datetime.now(timezone.utc) + stale_before = timestamp_text(now - QUEUE_ITEM_DELIVERY_CLAIM_TTL) + if q.session_queue.claim_delivery(item_id, claimant, now=timestamp_text(now), stale_before=stale_before): + return QueueItemDeliveryResult(status="claimed") + delivery = q.session_queue.delivery(item_id) + if delivery is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + return _held(delivery, now) + + return self._queries.run(claim) + + def confirm_queue_item_delivery(self, item_id: int) -> QueueItemDeliveryResult: + def confirm(q: Queries) -> None: + confirmed = q.session_queue.confirm_delivery(item_id, now=timestamp_text(datetime.now(timezone.utc))) + if not confirmed and q.session_queue.delivery(item_id) is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + + self._queries.run(confirm) + return QueueItemDeliveryResult(status="delivered") + + def release_queue_item_delivery(self, item_id: int, claimant: str) -> QueueItemDeliveryResult: + def release(q: Queries) -> None: + if not q.session_queue.release_delivery(item_id, claimant) and q.session_queue.delivery(item_id) is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + + self._queries.run(release) + return QueueItemDeliveryResult(status="released") + def _save_session(self, item_id: int, session: GraphExecutionState, *, only_unfinished: bool) -> bool: session_json = _session_json(session) @@ -1151,15 +1209,16 @@ def read(item_id: int) -> Optional[SessionQueueItem]: clones = self._queries.run(survey, read_only=True) - def admit(q: Queries) -> Optional[list[tuple[int, str, ValueToInsertTuple]]]: - """The clones the queue had room for, inserted; None when it is full.""" + def admit(q: Queries) -> Optional[list[tuple[int, str, int]]]: + """The clones the queue had room for, inserted one by one for their ids; None when it is full.""" q.locks.acquire(*_ADMISSION, also_shared=_PROTECTING_MEDIA) max_new_queue_items = max_queue_size - q.session_queue.pending_count(queue_id) if max_new_queue_items <= 0: return None - admitted = clones[:max_new_queue_items] - q.session_queue.insert_items([values for _, _, values in admitted]) - return admitted + return [ + (root_item_id, user_id, q.session_queue.insert_item(dict(zip(ITEM_COLUMNS, values, strict=True)))) + for root_item_id, user_id, values in clones[:max_new_queue_items] + ] admitted = self._queries.run(admit) if admitted is None: @@ -1168,7 +1227,11 @@ def admit(q: Queries) -> Optional[list[tuple[int, str, ValueToInsertTuple]]]: for root_item_id, user_id, _ in admitted: retried_item_ids_by_user.setdefault(user_id, []).append(root_item_id) retry_result = RetryItemsResult( - queue_id=queue_id, retried_item_ids=[root_item_id for root_item_id, _, _ in admitted] + queue_id=queue_id, + retried_item_ids=[root_item_id for root_item_id, _, _ in admitted], + retried_items=[ + RetriedQueueItem(source_item_id=root_item_id, item_id=item_id) for root_item_id, _, item_id in admitted + ], ) self.__invoker.services.events.emit_queue_items_retried( retry_result, diff --git a/invokeai/app/services/shared/database/queries/session_queue.py b/invokeai/app/services/shared/database/queries/session_queue.py index f543bf63eb5..e0d29451408 100644 --- a/invokeai/app/services/shared/database/queries/session_queue.py +++ b/invokeai/app/services/shared/database/queries/session_queue.py @@ -1,7 +1,8 @@ """The session queue: the items users enqueued, their workflow-call chains, and the counts the queue's views show. -An item is read as a dict of its row (every column of `session_queue`) with its owner's display name and email, the -form `SessionQueueItem.queue_item_from_dict()` and `SessionQueueItemSummary.queue_item_summary_from_dict()` take. +An item is read as a dict of its row (every column of `session_queue` but its delivery's) with its owner's display name +and email, the form `SessionQueueItem.queue_item_from_dict()` and `SessionQueueItemSummary.queue_item_summary_from_dict()` +take. Its delivery (which browser stages its results) is read on its own. An origin prefix matches as SQLite's `LIKE` always has, ignoring case; its `%` and `_` are taken as written. """ @@ -47,7 +48,10 @@ _Q = session_queue.c _R = session_queue_enqueue_receipts.c -_ITEM = (*session_queue.c, users.c.display_name.label("user_display_name"), users.c.email.label("user_email")) +_DELIVERY_COLUMNS = frozenset({"delivery_claimant", "delivery_claimed_at", "delivered_at"}) +_ROW = tuple(column for column in session_queue.c if column.name not in _DELIVERY_COLUMNS) + +_ITEM = (*_ROW, users.c.display_name.label("user_display_name"), users.c.email.label("user_email")) _ITEM_NAMES = tuple(column.name for column in _ITEM) _ITEMS = session_queue.outerjoin(users, users.c.user_id == _Q.user_id) @@ -343,8 +347,8 @@ class BatchCounts(NamedTuple): _ITEM_EXISTS = select(literal(1)).where(_Q.item_id == bindparam("item_id")) _LOCK_STATUS = select(_Q.status).where(_Q.item_id == bindparam("item_id")).with_for_update() # Locks the item's own row only: on a server a FOR UPDATE over a join would lock the owner's account row as well. -_LOCK_ITEM = select(*session_queue.c).where(_Q.item_id == bindparam("item_id")).with_for_update() -_LOCK_ITEM_NAMES = tuple(column.name for column in session_queue.c) +_LOCK_ITEM = select(*_ROW).where(_Q.item_id == bindparam("item_id")).with_for_update() +_LOCK_ITEM_NAMES = tuple(column.name for column in _ROW) _CLAIM = ( update(session_queue) @@ -508,6 +512,34 @@ def _stamp(status: str) -> Optional[str]: ).where(_R.user_id == bindparam("user_id")) _INSERT_RECEIPT = insert(session_queue_enqueue_receipts) +# Delivery leaves `updated_at` alone: it says nothing about the item that the queue's views show. +_UNDELIVERED = _Q.delivered_at.is_(None) +_CLAIM_DELIVERY = ( + update(session_queue) + .where( + _THE_ITEM, + _UNDELIVERED, + or_( + _Q.delivery_claimant.is_(None), + _Q.delivery_claimant == bindparam("claimant"), + _Q.delivery_claimed_at <= bindparam("stale_before"), + ), + ) + .values(delivery_claimant=bindparam("new_claimant"), delivery_claimed_at=bindparam("now"), updated_at=_Q.updated_at) +) +_CONFIRM_DELIVERY = ( + update(session_queue).where(_THE_ITEM, _UNDELIVERED).values(delivered_at=bindparam("now"), updated_at=_Q.updated_at) +) +_RELEASE_DELIVERY = ( + update(session_queue) + .where(_THE_ITEM, _UNDELIVERED, _Q.delivery_claimant == bindparam("claimant")) + .values(delivery_claimant=None, delivery_claimed_at=None, updated_at=_Q.updated_at) +) +_DELIVERY = select(_Q.delivered_at, _Q.delivery_claimant, _Q.delivery_claimed_at).where( + _Q.item_id == bindparam("item_id") +) +_OWNER = select(_Q.queue_id, _Q.user_id).where(_Q.item_id == bindparam("item_id")) + _ACTIVE_ITEM_IDS = select(_Q.item_id).where(_Q.status.in_([literal(status) for status in ACTIVE_QUEUE_STATUSES])) # History: finished items that no active workflow call still needs. A completed child is recovery state for its active # root until the root ends, and the intermediates cleanup protects the root's media through it. @@ -661,6 +693,19 @@ class Transition(NamedTuple): changed: Optional[dict[str, Any]] +class Delivery(NamedTuple): + """Who stages an item's results: when they were delivered, or who claims delivering them since when.""" + + delivered_at: Optional[str] + claimant: Optional[str] + claimed_at: Optional[str] + + +class ItemOwner(NamedTuple): + queue_id: str + user_id: str + + class SettledReceipt(NamedTuple): payload_hash: str batch_id: str @@ -1010,6 +1055,43 @@ def workflow_call_item_ids(self, conn: Connection, workflow_call_id: str) -> lis for item_id in conn.execute(_WORKFLOW_CALL_ITEMS, {"workflow_call_id": workflow_call_id}).scalars().all() ] + # --- Delivery --------------------------------------------------------------------------------------------------- + + @write + def claim_delivery(self, conn: Connection, item_id: int, claimant: str, *, now: str, stale_before: str) -> bool: + """Claims delivering the item's results for `claimant`, stamped `now`, unless they were delivered or another + claimant holds a claim made after `stale_before`; whether it did. (A claimant's own claim is renewed.)""" + parameters = { + "target_item_id": item_id, + "claimant": claimant, + "new_claimant": claimant, + "now": now, + "stale_before": stale_before, + } + return conn.execute(_CLAIM_DELIVERY, parameters).rowcount != 0 + + @write + def confirm_delivery(self, conn: Connection, item_id: int, *, now: str) -> bool: + """Marks the item's results delivered `now`; whether they were not before.""" + return conn.execute(_CONFIRM_DELIVERY, {"target_item_id": item_id, "now": now}).rowcount != 0 + + @write + def release_delivery(self, conn: Connection, item_id: int, claimant: str) -> bool: + """Drops `claimant`'s claim of the item's undelivered results; whether it held one.""" + return conn.execute(_RELEASE_DELIVERY, {"target_item_id": item_id, "claimant": claimant}).rowcount != 0 + + @read + def owner(self, conn: Connection, item_id: int) -> Optional[ItemOwner]: + """The item's queue and owner, without reading its session; None if it does not exist.""" + row = conn.execute(_OWNER, {"item_id": item_id}).first() + return ItemOwner(row[0], row[1]) if row is not None else None + + @read + def delivery(self, conn: Connection, item_id: int) -> Optional[Delivery]: + """The item's delivery; None if it does not exist.""" + row = conn.execute(_DELIVERY, {"item_id": item_id}).first() + return Delivery(row[0], row[1], row[2]) if row is not None else None + # --- Enqueueing ------------------------------------------------------------------------------------------------- @read diff --git a/invokeai/app/services/shared/database/schema/session_queue.py b/invokeai/app/services/shared/database/schema/session_queue.py index 0f1a48a3944..57f9e997f5c 100644 --- a/invokeai/app/services/shared/database/schema/session_queue.py +++ b/invokeai/app/services/shared/database/schema/session_queue.py @@ -46,6 +46,10 @@ Column("project_id", LongText()), # Counts the writes of `session`, so a writer can tell whether it still holds the latest one. Column("session_revision", BigInt(), nullable=False, server_default=default(0)), + # Which browser stages a retried item's results, so that only one does (2026_10_13_add_session_queue_delivery). + Column("delivery_claimant", LongText()), + Column("delivery_claimed_at", Timestamp()), + Column("delivered_at", Timestamp()), sqlite_autoincrement=True, ) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_13_add_session_queue_delivery.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_13_add_session_queue_delivery.py new file mode 100644 index 00000000000..d62d996dfb8 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_13_add_session_queue_delivery.py @@ -0,0 +1,33 @@ +"""Record who stages a queue item's results, so that browsers on different devices agree on one. + +A retried item's results are staged into the project it ran for by one browser. A browser claims the delivery +(`delivery_claimant`, `delivery_claimed_at`) before staging and confirms it (`delivered_at`) after; a claim that +is not confirmed lapses, so a browser that went away does not keep the results from every other one. +""" + +from sqlalchemy import Column, inspect + +from invokeai.app.services.shared.database.types import LongText, Timestamp +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( + PortableMigration, + PortableMigrationContext, +) + + +def _add_session_queue_delivery(context: PortableMigrationContext) -> None: + existing = {column["name"] for column in inspect(context.conn).get_columns("session_queue")} + for column in ( + Column("delivery_claimant", LongText()), + Column("delivery_claimed_at", Timestamp()), + Column("delivered_at", Timestamp()), + ): + if column.name not in existing: + context.op.add_column("session_queue", column) + + +def build_migration() -> PortableMigration: + return PortableMigration( + id="2026_10_13_add_session_queue_delivery", + depends_on="2026_10_08_add_board_project_membership", + callback=_add_session_queue_delivery, + ) diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index a78b1e299b2..4ab7ee87461 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -13843,6 +13843,76 @@ } } }, + "/api/v1/queue/{queue_id}/i/{item_id}/delivery": { + "post": { + "tags": ["queue"], + "summary": "Claim Queue Item Delivery", + "description": "Claims, confirms or releases staging a queue item's results, so that only one of its owner's browsers stages\nthem. A claim is held for a while unless confirmed, released or renewed.", + "operationId": "claim_queue_item_delivery", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "queue_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The queue id to perform this operation on", + "title": "Queue Id" + }, + "description": "The queue id to perform this operation on" + }, + { + "name": "item_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "The queue item whose results are delivered", + "title": "Item Id" + }, + "description": "The queue item whose results are delivered" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueItemDeliveryRequest", + "description": "The delivery change" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueItemDeliveryResult" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/queue/{queue_id}/counts_by_destination": { "get": { "tags": ["queue"], @@ -92775,6 +92845,53 @@ "title": "QueueClearedEvent", "type": "object" }, + "QueueItemDeliveryRequest": { + "properties": { + "action": { + "type": "string", + "enum": ["claim", "confirm", "release"], + "title": "Action", + "description": "claim: take delivering the results; confirm: mark them delivered; release: give up a claim" + }, + "claimant": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "title": "Claimant", + "description": "An id the client chooses for the browser tab or run claiming" + } + }, + "type": "object", + "required": ["action", "claimant"], + "title": "QueueItemDeliveryRequest", + "description": "A change to who stages a queue item's results, so that only one browser does." + }, + "QueueItemDeliveryResult": { + "properties": { + "status": { + "type": "string", + "enum": ["claimed", "delivered", "held", "released"], + "title": "Status", + "description": "claimed: the claimant may deliver; delivered: the results were delivered; held: another claimant is delivering them; released: the claimant holds no claim" + }, + "retry_after_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Retry After Ms", + "description": "When held, how long until the other claim lapses, in milliseconds" + } + }, + "type": "object", + "required": ["status"], + "title": "QueueItemDeliveryResult", + "description": "Who stages a queue item's results after a delivery request." + }, "QueueItemStatusChangedEvent": { "description": "Event model for queue_item_status_changed", "properties": { @@ -93046,9 +93163,24 @@ "description": "The retried root queue item IDs keyed by owner user ID.", "title": "Retried Item Ids By User", "type": "object" + }, + "retried_items": { + "description": "Each retried root queue item with the queue item that re-runs it", + "items": { + "$ref": "#/components/schemas/RetriedQueueItem" + }, + "title": "Retried Items", + "type": "array" } }, - "required": ["timestamp", "queue_id", "retried_item_ids", "user_ids", "retried_item_ids_by_user"], + "required": [ + "timestamp", + "queue_id", + "retried_item_ids", + "user_ids", + "retried_item_ids_by_user", + "retried_items" + ], "title": "QueueItemsRetriedEvent", "type": "object" }, @@ -98092,6 +98224,23 @@ "title": "ResourceOrigin", "description": "The origin of a resource (eg image).\n\n- INTERNAL: The resource was created by the application.\n- EXTERNAL: The resource was not created by the application.\nThis may be a user-initiated upload, or an internal application upload (eg Canvas init image)." }, + "RetriedQueueItem": { + "properties": { + "source_item_id": { + "type": "integer", + "title": "Source Item Id", + "description": "The ID of the retried root queue item" + }, + "item_id": { + "type": "integer", + "title": "Item Id", + "description": "The ID of the queue item that re-runs it" + } + }, + "type": "object", + "required": ["source_item_id", "item_id"], + "title": "RetriedQueueItem" + }, "RetryItemsResult": { "properties": { "queue_id": { @@ -98106,6 +98255,14 @@ "type": "array", "title": "Retried Item Ids", "description": "The IDs of the queue items that were retried" + }, + "retried_items": { + "items": { + "$ref": "#/components/schemas/RetriedQueueItem" + }, + "type": "array", + "title": "Retried Items", + "description": "Each retried root queue item with the queue item that re-runs it" } }, "type": "object", diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index 1c635d6f3c3..46d86c833be 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -3478,6 +3478,27 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/queue/{queue_id}/i/{item_id}/delivery": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Claim Queue Item Delivery + * @description Claims, confirms or releases staging a queue item's results, so that only one of its owner's browsers stages + * them. A claim is held for a while unless confirmed, released or renewed. + */ + post: operations["claim_queue_item_delivery"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/queue/{queue_id}/counts_by_destination": { parameters: { query?: never; @@ -39713,6 +39734,40 @@ export type components = { */ user_id: string | null; }; + /** + * QueueItemDeliveryRequest + * @description A change to who stages a queue item's results, so that only one browser does. + */ + QueueItemDeliveryRequest: { + /** + * Action + * @description claim: take delivering the results; confirm: mark them delivered; release: give up a claim + * @enum {string} + */ + action: "claim" | "confirm" | "release"; + /** + * Claimant + * @description An id the client chooses for the browser tab or run claiming + */ + claimant: string; + }; + /** + * QueueItemDeliveryResult + * @description Who stages a queue item's results after a delivery request. + */ + QueueItemDeliveryResult: { + /** + * Status + * @description claimed: the claimant may deliver; delivered: the results were delivered; held: another claimant is delivering them; released: the claimant holds no claim + * @enum {string} + */ + status: "claimed" | "delivered" | "held" | "released"; + /** + * Retry After Ms + * @description When held, how long until the other claim lapses, in milliseconds + */ + retry_after_ms?: number | null; + }; /** * QueueItemStatusChangedEvent * @description Event model for queue_item_status_changed @@ -39884,6 +39939,11 @@ export type components = { retried_item_ids_by_user: { [key: string]: number[]; }; + /** + * Retried Items + * @description Each retried root queue item with the queue item that re-runs it + */ + retried_items: components["schemas"]["RetriedQueueItem"][]; }; /** * Qwen35EncoderField @@ -42651,6 +42711,19 @@ export type components = { * @enum {string} */ ResourceOrigin: "internal" | "external"; + /** RetriedQueueItem */ + RetriedQueueItem: { + /** + * Source Item Id + * @description The ID of the retried root queue item + */ + source_item_id: number; + /** + * Item Id + * @description The ID of the queue item that re-runs it + */ + item_id: number; + }; /** RetryItemsResult */ RetryItemsResult: { /** @@ -42663,6 +42736,11 @@ export type components = { * @description The IDs of the queue items that were retried */ retried_item_ids: number[]; + /** + * Retried Items + * @description Each retried root queue item with the queue item that re-runs it + */ + retried_items?: components["schemas"]["RetriedQueueItem"][]; }; /** * Round Float @@ -60029,6 +60107,44 @@ export interface operations { }; }; }; + claim_queue_item_delivery: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The queue id to perform this operation on */ + queue_id: string; + /** @description The queue item whose results are delivered */ + item_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QueueItemDeliveryRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QueueItemDeliveryResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; counts_by_destination: { parameters: { query: { diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index e8a49c4183f..8d2183f5cc8 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,15 +2,15 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 794977, + "brotliBytes": 796613, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 793689, + "gzipBytes": 795480, "imageRawBytes": 44398, - "initialRawBytes": 2042114, - "largestAssetRawBytes": 749029, + "initialRawBytes": 2048139, + "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, - "ownedRawBytes": 98621, + "ownedRawBytes": 98845, "requestCount": 35, "scriptRequestCount": 19, "sourceOwners": [ @@ -241,6 +241,7 @@ "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", + "source:src/platform/ui/SliderNumberField.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", "source:src/platform/ui/Tabs.tsx", "source:src/platform/ui/ToggleDot.tsx", @@ -346,15 +347,15 @@ }, "editor": { "baseline": { - "brotliBytes": 1171873, + "brotliBytes": 1177491, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1165072, + "gzipBytes": 1170684, "imageRawBytes": 44398, - "initialRawBytes": 3208701, - "largestAssetRawBytes": 749029, + "initialRawBytes": 3226866, + "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, - "ownedRawBytes": 150716, + "ownedRawBytes": 151806, "requestCount": 72, "scriptRequestCount": 56, "sourceOwners": [ @@ -696,6 +697,7 @@ "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", "source:src/features/workflow/core/forLoops.ts", + "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", @@ -776,6 +778,7 @@ "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", + "source:src/platform/ui/SliderNumberField.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", "source:src/platform/ui/Tabs.tsx", "source:src/platform/ui/ToggleDot.tsx", @@ -1092,7 +1095,7 @@ "source": "src/app/WorkbenchApp.tsx" } }, - "capturedAt": "2026-10-09", + "capturedAt": "2026-10-11", "developmentInvalidation": { "platformUiBarrel": { "specifier": "@platform/ui", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index e04bb3bd8c8..718797b8620 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,6 +1,6 @@ { "browserExecutable": "/home/toast/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome", - "capturedAt": "2026-10-15", + "capturedAt": "2026-10-17", "routes": [ { "activatedResourceBaseline": { @@ -14,12 +14,12 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 130.60000000149012, + "domContentLoadedMedianMs": 128, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 130.79999999701977, + "loadMedianMs": 128.09999999403954, "longestTaskMaxMs": 82, "owner": "app", "projectSwitchMedianMs": 0, @@ -30,13 +30,13 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 30, - "scriptRawBytes": 1847804, + "scriptRawBytes": 1852857, "scriptRequestCount": 26, - "totalRawBytes": 2079171 + "totalRawBytes": 2090003 }, - "routeReadyMedianMs": 275.80000000447035, + "routeReadyMedianMs": 266.79999999701977, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -52,13 +52,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.10000000149012, + "domContentLoadedMedianMs": 125.70000000298023, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.20000000298023, - "longestTaskMaxMs": 81, + "loadMedianMs": 125.79999999701977, + "longestTaskMaxMs": 79, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -68,13 +68,13 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 30, - "scriptRawBytes": 1847804, + "scriptRawBytes": 1852857, "scriptRequestCount": 26, - "totalRawBytes": 2079171 + "totalRawBytes": 2090003 }, - "routeReadyMedianMs": 277, + "routeReadyMedianMs": 278.09999999403954, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -90,15 +90,15 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.5, + "domContentLoadedMedianMs": 128.70000000298023, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.70000000298023, - "longestTaskMaxMs": 173, + "loadMedianMs": 128.8999999910593, + "longestTaskMaxMs": 174, "owner": "workbench", - "projectSwitchMedianMs": 38.30000000447035, + "projectSwitchMedianMs": 35.20000000298023, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -106,13 +106,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 88, - "scriptRawBytes": 4106504, + "scriptRawBytes": 4131338, "scriptRequestCount": 83, - "totalRawBytes": 4382269 + "totalRawBytes": 4412882 }, - "routeReadyMedianMs": 430.19999999552965, + "routeReadyMedianMs": 443.29999999701977, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -128,15 +128,15 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 129.60000000149012, + "domContentLoadedMedianMs": 127.69999998807907, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 129.69999999552965, - "longestTaskMaxMs": 180, + "loadMedianMs": 127.8999999910593, + "longestTaskMaxMs": 166, "owner": "workbench", - "projectSwitchMedianMs": 44.099999994039536, + "projectSwitchMedianMs": 44, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -144,13 +144,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 88, - "scriptRawBytes": 4106504, + "scriptRawBytes": 4131338, "scriptRequestCount": 83, - "totalRawBytes": 4382269 + "totalRawBytes": 4412882 }, - "routeReadyMedianMs": 466.3999999985099, + "routeReadyMedianMs": 442.79999999701977, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -159,19 +159,19 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305459, + "largestAssetRawBytes": 305464, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 514291, + "scriptRawBytes": 516145, "scriptRequestCount": 9, - "totalRawBytes": 514291 + "totalRawBytes": 516145 }, - "domContentLoadedMedianMs": 125.5, + "domContentLoadedMedianMs": 134.30000001192093, "id": "editor-canvas", - "layoutAckMedianMs": 2.6999999955296516, - "layoutReturnSwitchMedianMs": 57.899999998509884, - "layoutSwitchMedianMs": 76.60000000149012, - "loadMedianMs": 125.60000000149012, + "layoutAckMedianMs": 2.7000000029802322, + "layoutReturnSwitchMedianMs": 62.099999994039536, + "layoutSwitchMedianMs": 81.19999998807907, + "loadMedianMs": 134.5, "longestTaskMaxMs": 179, "owner": "canvas", "projectSwitchMedianMs": 0, @@ -182,13 +182,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 97, - "scriptRawBytes": 4620795, + "scriptRawBytes": 4647483, "scriptRequestCount": 92, - "totalRawBytes": 4896560 + "totalRawBytes": 4929027 }, - "routeReadyMedianMs": 1153.7999999970198, + "routeReadyMedianMs": 1204.2999999970198, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -197,20 +197,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305459, + "largestAssetRawBytes": 305464, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 514291, + "scriptRawBytes": 516145, "scriptRequestCount": 9, - "totalRawBytes": 514291 + "totalRawBytes": 516145 }, - "domContentLoadedMedianMs": 126.59999999403954, + "domContentLoadedMedianMs": 130.59999999403954, "id": "editor-canvas", - "layoutAckMedianMs": 3.100000001490116, - "layoutReturnSwitchMedianMs": 52.70000000298023, - "layoutSwitchMedianMs": 72.20000000298023, - "loadMedianMs": 126.69999999552965, - "longestTaskMaxMs": 196, + "layoutAckMedianMs": 3, + "layoutReturnSwitchMedianMs": 53.5, + "layoutSwitchMedianMs": 77.3999999910593, + "loadMedianMs": 130.70000000298023, + "longestTaskMaxMs": 197, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -220,13 +220,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 97, - "scriptRawBytes": 4620795, + "scriptRawBytes": 4647483, "scriptRequestCount": 92, - "totalRawBytes": 4896560 + "totalRawBytes": 4929027 }, - "routeReadyMedianMs": 1147.1000000014901, + "routeReadyMedianMs": 1190.8999999910593, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -235,20 +235,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187100, + "largestAssetRawBytes": 201666, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420925, + "scriptRawBytes": 443711, "scriptRequestCount": 6, - "totalRawBytes": 436338 + "totalRawBytes": 459124 }, - "domContentLoadedMedianMs": 125, + "domContentLoadedMedianMs": 130.79999999701977, "id": "editor-workflow", - "layoutAckMedianMs": 2.7000000029802322, - "layoutReturnSwitchMedianMs": 38.600000001490116, - "layoutSwitchMedianMs": 82.60000000149012, - "loadMedianMs": 125.09999999403954, - "longestTaskMaxMs": 174, + "layoutAckMedianMs": 3, + "layoutReturnSwitchMedianMs": 37.1000000089407, + "layoutSwitchMedianMs": 79.29999999701977, + "loadMedianMs": 130.8999999910593, + "longestTaskMaxMs": 176, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -258,13 +258,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 95, - "scriptRawBytes": 4527429, + "scriptRawBytes": 4575049, "scriptRequestCount": 89, - "totalRawBytes": 4818607 + "totalRawBytes": 4872006 }, - "routeReadyMedianMs": 1171.6000000014901, + "routeReadyMedianMs": 1193.4000000059605, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -273,20 +273,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187100, + "largestAssetRawBytes": 201666, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420925, + "scriptRawBytes": 443711, "scriptRequestCount": 6, - "totalRawBytes": 436338 + "totalRawBytes": 459124 }, - "domContentLoadedMedianMs": 123.70000000298023, + "domContentLoadedMedianMs": 131, "id": "editor-workflow", - "layoutAckMedianMs": 2.6999999955296516, - "layoutReturnSwitchMedianMs": 41.80000000447035, - "layoutSwitchMedianMs": 128.80000000447035, - "loadMedianMs": 123.80000000447035, - "longestTaskMaxMs": 259, + "layoutAckMedianMs": 3, + "layoutReturnSwitchMedianMs": 46.3999999910593, + "layoutSwitchMedianMs": 156, + "loadMedianMs": 131.09999999403954, + "longestTaskMaxMs": 305, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -296,13 +296,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 95, - "scriptRawBytes": 4527429, + "scriptRawBytes": 4575049, "scriptRequestCount": 89, - "totalRawBytes": 4818607 + "totalRawBytes": 4872006 }, - "routeReadyMedianMs": 1192.8999999985099, + "routeReadyMedianMs": 1273.2999999970198, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -318,13 +318,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.79999999701977, + "domContentLoadedMedianMs": 129.19999998807907, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 41.100000001490116, - "loadMedianMs": 125.89999999850988, - "longestTaskMaxMs": 174, + "layoutSwitchMedianMs": 42.29999999701977, + "loadMedianMs": 129.29999999701977, + "longestTaskMaxMs": 175, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -334,13 +334,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 88, - "scriptRawBytes": 4106504, + "scriptRawBytes": 4131338, "scriptRequestCount": 83, - "totalRawBytes": 4382269 + "totalRawBytes": 4412882 }, - "routeReadyMedianMs": 1431.3999999985099, + "routeReadyMedianMs": 1437.8999999910593, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -356,13 +356,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 126.89999999850988, + "domContentLoadedMedianMs": 132.5, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 45.399999998509884, - "loadMedianMs": 127.09999999403954, - "longestTaskMaxMs": 170, + "layoutSwitchMedianMs": 50.8999999910593, + "loadMedianMs": 132.69999998807907, + "longestTaskMaxMs": 181, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -372,13 +372,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 205544, + "otherRawBytes": 211323, "requestCount": 88, - "scriptRawBytes": 4106504, + "scriptRawBytes": 4131338, "scriptRequestCount": 83, - "totalRawBytes": 4382269 + "totalRawBytes": 4412882 }, - "routeReadyMedianMs": 1396.3999999985099, + "routeReadyMedianMs": 1443.8999999910593, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -652,6 +652,7 @@ "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", + "source:src/platform/ui/SliderNumberField.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", "source:src/platform/ui/Tabs.tsx", "source:src/platform/ui/ToggleDot.tsx", @@ -1201,6 +1202,7 @@ "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", "source:src/features/workflow/core/forLoops.ts", + "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/libraryTags.ts", "source:src/features/workflow/core/modelRequirements.ts", @@ -1576,6 +1578,7 @@ "source:src/workbench/image-actions/galleryOptimisticRollback.ts", "source:src/workbench/image-actions/imageRecall.ts", "source:src/workbench/image-actions/index.ts", + "source:src/workbench/image-actions/mediaUsage.ts", "source:src/workbench/image-actions/recallParametersBridge.ts", "source:src/workbench/image-actions/useDeletionConfirmation.tsx", "source:src/workbench/image-actions/useFindGalleryItem.ts", @@ -2290,6 +2293,7 @@ "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", "source:src/features/workflow/core/forLoops.ts", + "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/layerWorkflow.ts", "source:src/features/workflow/core/libraryTags.ts", @@ -2675,6 +2679,7 @@ "source:src/workbench/image-actions/galleryOptimisticRollback.ts", "source:src/workbench/image-actions/imageRecall.ts", "source:src/workbench/image-actions/index.ts", + "source:src/workbench/image-actions/mediaUsage.ts", "source:src/workbench/image-actions/recallParametersBridge.ts", "source:src/workbench/image-actions/useDeletionConfirmation.tsx", "source:src/workbench/image-actions/useFindGalleryItem.ts", @@ -3485,6 +3490,7 @@ "source:src/features/queue/data/events.ts", "source:src/features/queue/data/generationDevicesStore.ts", "source:src/features/queue/data/httpRealtimeQueueBackend.ts", + "source:src/features/queue/data/invocationCacheQueries.ts", "source:src/features/queue/data/itemProgressStore.ts", "source:src/features/queue/data/mappers.ts", "source:src/features/queue/data/progressImageStore.ts", @@ -3504,13 +3510,16 @@ "source:src/features/queue/runtime/receiptAcknowledgements.ts", "source:src/features/queue/runtime/utilityGraph.ts", "source:src/features/queue/ui/CancelQueueItemButton.tsx", + "source:src/features/queue/ui/InvocationCacheFooter.tsx", "source:src/features/queue/ui/ModelCacheFooter.tsx", "source:src/features/queue/ui/NowNextSection.tsx", "source:src/features/queue/ui/QueueDataRuntime.tsx", "source:src/features/queue/ui/QueueFilterTabs.tsx", + "source:src/features/queue/ui/QueueFooter.tsx", "source:src/features/queue/ui/QueueHeaderActions.tsx", "source:src/features/queue/ui/QueueHeaderLabel.tsx", "source:src/features/queue/ui/QueueHeaderMenu.tsx", + "source:src/features/queue/ui/QueueItemDetailActions.tsx", "source:src/features/queue/ui/QueueItemDetails.tsx", "source:src/features/queue/ui/QueueItemRow.tsx", "source:src/features/queue/ui/QueueItemThumbnail.tsx", @@ -3520,6 +3529,7 @@ "source:src/features/queue/ui/QueueUiContext.tsx", "source:src/features/queue/ui/QueueWidgetView.tsx", "source:src/features/queue/ui/RecentSection.tsx", + "source:src/features/queue/ui/RetryQueueItemButton.tsx", "source:src/features/queue/ui/currentBatchItems.ts", "source:src/features/queue/ui/formatDuration.ts", "source:src/features/queue/ui/index.ts", @@ -3555,6 +3565,7 @@ "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", "source:src/features/workflow/core/forLoops.ts", + "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/graphLayout.ts", "source:src/features/workflow/core/graphToDocument.ts", @@ -3631,13 +3642,16 @@ "source:src/features/workflow/ui/library/useWorkflowPublication.ts", "source:src/features/workflow/ui/linear/FormBuilderTab.tsx", "source:src/features/workflow/ui/linear/LinearFormView.tsx", + "source:src/features/workflow/ui/linear/LinkifiedText.tsx", "source:src/features/workflow/ui/linear/NodeFieldControl.tsx", + "source:src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx", "source:src/features/workflow/ui/linear/NodeInspector.tsx", "source:src/features/workflow/ui/linear/WorkflowDetailsTab.tsx", "source:src/features/workflow/ui/linear/WorkflowJsonTab.tsx", "source:src/features/workflow/ui/linear/WorkflowLinearPanel.tsx", "source:src/features/workflow/ui/linear/formBuilderDnd.ts", "source:src/features/workflow/ui/linear/formElementMeta.ts", + "source:src/features/workflow/ui/linear/linkify.ts", "source:src/features/workflow/ui/loopLinkage.ts", "source:src/features/workflow/ui/nodeChrome.tsx", "source:src/features/workflow/ui/pendingLibraryWorkflowLoadRuntime.ts", @@ -3979,6 +3993,7 @@ "source:src/workbench/image-actions/galleryOptimisticRollback.ts", "source:src/workbench/image-actions/imageRecall.ts", "source:src/workbench/image-actions/index.ts", + "source:src/workbench/image-actions/mediaUsage.ts", "source:src/workbench/image-actions/recallParametersBridge.ts", "source:src/workbench/image-actions/useDeletionConfirmation.tsx", "source:src/workbench/image-actions/useFindGalleryItem.ts", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index b3acd165de8..5ed4de02fc2 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -259,7 +259,8 @@ "canceled": "Canceled", "completed": "Completed", "failed": "Failed", - "pending": "Pending" + "pending": "Pending", + "waiting": "Waiting" }, "total": "Total", "unknownError": "Unknown error.", @@ -1873,6 +1874,17 @@ "saveFailed": "Could not save the vocabulary.", "loadFailed": "Could not load the vocabulary." }, + "maxQueueHistory": { + "adminOnly": "Only an administrator can change how much queue history the server keeps.", + "description": "Finished, failed and canceled items kept when InvokeAI starts. Leave blank to keep everything, or enter 0 to clear finished history on every start.", + "inputLabel": "Queue history limit", + "invalid": "Enter a whole number of items, or leave the field blank.", + "keepAll": "Keep all", + "modified": "Queue history", + "readFailed": "Could not read the server's queue history setting.", + "restartRequired": "Restart InvokeAI for changes to take effect.", + "saveFailed": "Could not save the queue history limit." + }, "databaseMaintenance": { "compactDatabase": "Compact database", "confirmTitle": "Run database VACUUM?", @@ -2021,6 +2033,10 @@ "label": "Generation devices", "description": "Server-wide accelerator selection. Changes require a server restart." }, + "maxQueueHistory": { + "label": "Queue history", + "description": "How many finished queue items the server keeps on startup. Changes require a server restart." + }, "workspaceActions": { "label": "Data & workspace", "description": "Reset the layout, manage intermediates, or permanently delete saved projects and settings. Administrators can also run database maintenance." @@ -2846,6 +2862,7 @@ "previousItem": "Show previous item", "swapComparisonImages": "Swap comparison images", "toggleFilmstrip": "Toggle filmstrip", + "toggleMetadata": "Toggle details", "zoomToActual": "Zoom preview to 100%", "zoomToFit": "Zoom preview to fit" }, @@ -2910,7 +2927,15 @@ "actionConfirmationTitle": "{{action}}?", "backendItem": "Backend queue item {{id}}.", "batch": "Batch", + "batchCancellationRequested": "Batch cancellation requested", + "batchCanceledCount_one": "{{count}} queue item was canceled.", + "batchCanceledCount_other": "{{count}} queue items were canceled.", + "batchNothingToCancel": "Nothing left to cancel", + "batchNothingToCancelDescription": "Every item in this batch had already finished.", "cancelAllExceptCurrent": "Cancel all except current item", + "cancelBatch": "Cancel batch", + "cancelBatchConfirmationBody": "This cancels every unfinished item in this batch. Other batches keep running.", + "cancelBatchTitle": "Cancel this batch?", "cancelAllExceptCurrentRequested": "All pending scoped items except the current item were requested.", "cancelAllItems": "Cancel All Items", "cancelConfirmationBody": "This requests cancellation for queue work in the current scope. In-progress items may stop after the backend acknowledges cancellation.", @@ -2922,6 +2947,12 @@ "clearFailedConfirmationBody": "This permanently removes failed queue items in the current queue scope.", "clearFailedItems": "Clear Failed Items", "clearFailedTitle": "Clear failed queue items?", + "clearFinishedConfirmationBody": "This permanently removes completed, canceled and failed queue items in the current queue scope. Pending and running work is kept.", + "clearFinishedItems": "Clear Finished Items", + "clearFinishedTitle": "Clear finished queue items?", + "copyFailed": "Failed to copy JSON", + "copyJson": "Copy JSON", + "couldNotCancelBatch": "Could not cancel this batch.", "clearQueue": "Clear Queue", "clearQueueConfirmationBody": "This permanently clears queue items in the current queue scope.", "clearQueueTitle": "Clear queue?", @@ -2930,9 +2961,20 @@ "couldNotCancelItems": "Could not cancel scoped queue items.", "couldNotChangeProcessor": "Could not change the queue processor.", "couldNotClearFailedItems": "Could not clear failed queue items.", + "couldNotClearFinishedItems": "Could not clear finished queue items.", + "couldNotRetryItem": "Could not retry this item.", "couldNotClearQueue": "Could not clear the queue.", "currentBatch": "Current Batch", + "detailLoadFailed": "The saved execution data could not be loaded.", + "detailLoading": "Loading saved execution data…", + "detailViews": { + "graph": "Graph", + "item": "Item", + "results": "Results", + "workflow": "Workflow" + }, "failedItemsCleared": "Failed queue items cleared", + "failedToCancelBatch": "Failed to cancel batch", "failedToCancelCurrentItem": "Failed to cancel current item", "failedToCancelItems": "Failed to cancel queue items", "failedToClearItems": "Failed to clear queue items", @@ -2940,8 +2982,31 @@ "failedToPauseProcessor": "Failed to pause processor", "failedToResumeProcessor": "Failed to resume processor", "filterRecentByStatus": "Filter recent queue items by status", + "finishedItemsCleared": "Finished queue items cleared", "generating_one": "{{count}} generating", "generating_other": "{{count}} generating", + "invocationCache": { + "cached": "Cached", + "clear": "Clear cache", + "clearDone": "Invocation cache cleared", + "clearFailed": "Clear invocation cache failed", + "couldNotChange": "Could not change the invocation cache.", + "disable": "Disable", + "disableDone": "Invocation cache disabled", + "disableFailed": "Disable invocation cache failed", + "disabled": "Disabled", + "enable": "Enable", + "enableDone": "Invocation cache enabled", + "enableFailed": "Enable invocation cache failed", + "hits": "Hits", + "label": "Invocation Cache", + "loading": "Loading…", + "misses": "Misses", + "noCapacity": "The server keeps no invocation cache. Set node_cache_size above 0 in invokeai.yaml and restart to use it.", + "off": "Off", + "sizeOfMax": "{{size}} / {{max}} cached", + "unavailable": "Unavailable" + }, "itemJsonLabel": "Queue item {{id}} JSON", "itemProgress": "Queue item progress", "itemTitle": "Queue item #{{id}}", @@ -2980,6 +3045,13 @@ "recallUnavailableCapabilities": "Nothing recallable yet — model capabilities have not loaded. If loading failed, retry from the Generate panel.", "recallUnavailableForItem": "Nothing recallable for this item.", "resumeProcessor": "Resume Processor", + "retryFailed": "Retry failed", + "retryItem": "Retry queue item #{{id}}", + "retryNotQueued": "Nothing to retry", + "retryNotQueuedDescription": "Queue item #{{id}} is not a failed or canceled job the server can retry.", + "retryQueued": "Retry queued", + "retryQueuedDescription": "Queue item #{{id}} was queued again from its saved session.", + "retryQueuedProjectClosedDescription": "Queue item #{{id}} was queued again. Its result is staged on its project's Canvas in a tab where that project is open; with none open, it will not appear.", "sendToCanvas": "Send to canvas", "sendToCanvasComingSoon": "Sending queue results to the canvas is coming soon.", "settingsRecalled": "Settings recalled", @@ -3353,6 +3425,33 @@ "form": "Form", "formBuilder": { "addElement": "Add form element", + "addOption": "Add option", + "boundsHint": "Blank bounds use the node's own limits; bounds never widen them.", + "control": "Control", + "controlDropdown": "Dropdown", + "controlNumberInput": "Input", + "controlNumberInputAndSlider": "Input + slider", + "controlSlider": "Slider", + "controlTextInput": "Input", + "controlTextarea": "Text area", + "dropdownOptions": "Dropdown options", + "fieldSettings": "Field control settings", + "fieldSettingsHint": "How the form shows this field.", + "maximum": "Maximum", + "minimum": "Minimum", + "noOptions": "No options yet; the dropdown stays disabled.", + "optionLabel": "Option {{index}} label", + "optionLabelPlaceholder": "Label", + "optionValue": "Option {{index}} value", + "optionValuePlaceholder": "Value", + "removeOption": "Remove option {{index}}", + "invertedBounds": "The minimum is above the maximum; the form ignores both until one is corrected.", + "sliderNeedsBounds": "A slider needs a minimum and a maximum with at least one step between them; until then the form shows an input.", + "boundsOutsideNode": "A bound lies beyond the node's own limits and would leave no valid value; the form ignores it until it is corrected.", + "stepOutsideNode": "The node accepts only whole steps for whole numbers, and only multiples of its own step; the form ignores this step until it is corrected.", + "noValidValue": "No multiple of the step lies between the bounds, so no value is valid and Invoke stays blocked. Widen the bounds or change the step.", + "unnamedOption": "Option {{index}}", + "step": "Step", "containerEmpty": "Empty container — drag cards here", "dropHere": "Drop here", "elements": { @@ -3443,6 +3542,8 @@ "tags": "Tags", "title": "Title", "toolbar": { + "arrangeNodes": "Arrange nodes", + "arrangeSelectedNodes": "Arrange selected nodes", "boxSelect": "Box select (drag to select nodes)", "eraser": "Eraser (click nodes or edges to delete)", "fitView": "Fit view", @@ -4148,6 +4249,22 @@ "deleteConfirmLabel": "Delete", "deleteConfirmSkip": "Don't ask me again", "deleteConfirmSkipHint": "You can turn this confirmation back on in Settings under Gallery.", + "deleteInUse": { + "heading": "Still used in this project:", + "surface": { + "canvasControl": "Canvas control layers", + "canvasInpaintMask": "Canvas inpaint masks", + "canvasRaster": "Canvas raster layers", + "canvasRegionalGuidance": "Canvas regional guidance", + "generateReferenceImages": "Generate reference images", + "upscale": "Upscale source image", + "video": "Video panel", + "workflow": "Workflow “{{name}}”" + }, + "usedCount_one": "{{count}} selected item", + "usedCount_other": "{{count}} selected items", + "warning": "The affected work keeps pointing at media that will no longer exist." + }, "deleteImagesConfirmBody_one": "This permanently deletes the image from every board and from disk. This cannot be undone.", "deleteImagesConfirmBody_other": "This permanently deletes these images from every board and from disk. This cannot be undone.", "deleteImagesConfirmTitle_one": "Delete image?", @@ -4180,8 +4297,12 @@ "loadWorkflow": { "editorUnavailable": "The workflow editor is not available in this layout.", "failed": "Could not load the image's workflow", + "failedVideo": "Could not load the video's workflow", + "label": "Load Workflow", "loadedLabel": "Loaded “{{name}}” from image", - "missing": "This image has no workflow embedded." + "loadedLabelVideo": "Loaded “{{name}}” from video", + "missing": "This image has no workflow embedded.", + "missingVideo": "This video has no workflow embedded." }, "delete": { "failure_one": "Could not delete {{count}} item.", diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index ded68687021..5eeb92fef99 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -200,7 +200,10 @@ test('the HTTP reset contract selects profiles explicitly and restores the start 100 ); assert.equal(projects.length, 40); - assert.deepEqual(runtimeConfig, { config: { generation_devices: 'auto' }, set_fields: [] }); + assert.deepEqual(runtimeConfig, { + config: { generation_devices: 'auto', max_queue_history: null }, + set_fields: [], + }); assert.deepEqual(await readJson('/__reset', { method: 'POST' }), { counts: MOCK_BACKEND_PROFILE_COUNTS.empty, @@ -938,3 +941,157 @@ test('workflow thumbnails round-trip their bytes and refresh the listed URL on e assert.equal(await listedUrl(), null); }); }); + +test('queue retry clones failed or canceled roots once, cancel-by-batch spares other batches, prune keeps active work', async () => { + await withRepresentativeBackend(async (backend) => { + const items = await getJson(backend, '/api/v1/queue/default/list_all'); + const failed = items.find((item) => item.status === 'failed'); + const pending = items.find((item) => item.status === 'pending'); + const completed = items.find((item) => item.status === 'completed'); + + assert.ok(failed && pending && completed, 'the representative queue has failed, pending and completed items'); + + const summaries = await getJson(backend, '/api/v1/queue/default/item_summaries_by_ids', { + body: JSON.stringify({ item_ids: [completed.item_id, 999_999, failed.item_id] }), + method: 'POST', + }); + // In the requested order, unknown ids skipped, with the lineage and status a scoped clear chooses roots by. + assert.deepEqual( + summaries.map(({ item_id, parent_item_id, status }) => ({ item_id, parent_item_id, status })), + [ + { item_id: completed.item_id, parent_item_id: null, status: 'completed' }, + { item_id: failed.item_id, parent_item_id: null, status: 'failed' }, + ] + ); + assert.equal('session' in summaries[0], false); + + const retried = await getJson(backend, '/api/v1/queue/default/retry_items_by_id', { + body: JSON.stringify([failed.item_id, failed.item_id, completed.item_id]), + method: 'PUT', + }); + const reruns = (await getJson(backend, '/api/v1/queue/default/list_all')).filter( + (item) => item.retried_from_item_id === failed.item_id + ); + + // Like the backend, the result names the retried root, not its rerun; the pairing names the rerun. + assert.deepEqual(retried.retried_item_ids, [failed.item_id]); + assert.equal(reruns.length, 1); + const [clone] = reruns; + assert.deepEqual(retried.retried_items, [{ item_id: clone.item_id, source_item_id: failed.item_id }]); + assert.equal(clone.status, 'pending'); + assert.notEqual(clone.session_id, failed.session_id); + assert.deepEqual(clone.session.results, {}); + + // Retrying the rerun pairs the new rerun with it while the lineage still names the original. + await getJson(backend, `/api/v1/queue/default/i/${clone.item_id}/cancel`, { method: 'PUT' }); + const again = await getJson(backend, '/api/v1/queue/default/retry_items_by_id', { + body: JSON.stringify([clone.item_id]), + method: 'PUT', + }); + assert.deepEqual(again.retried_item_ids, [clone.item_id]); + const secondRerun = await getJson(backend, `/api/v1/queue/default/i/${again.retried_items[0].item_id}`); + assert.equal(secondRerun.retried_from_item_id, failed.item_id); + + // One claimant at a time stages a rerun's results; a confirmed rerun stays delivered. + const deliver = (action, claimant) => + getJson(backend, `/api/v1/queue/default/i/${clone.item_id}/delivery`, { + body: JSON.stringify({ action, claimant }), + method: 'POST', + }); + assert.deepEqual(await deliver('claim', 'tab:a'), { status: 'claimed' }); + const held = await deliver('claim', 'tab:b'); + assert.equal(held.status, 'held'); + assert.ok(held.retry_after_ms > 0); + assert.deepEqual(await deliver('release', 'tab:a'), { status: 'released' }); + assert.deepEqual(await deliver('claim', 'tab:b'), { status: 'claimed' }); + assert.deepEqual(await deliver('confirm', 'tab:b'), { status: 'delivered' }); + assert.deepEqual(await deliver('claim', 'tab:a'), { status: 'delivered' }); + + const canceled = await getJson(backend, '/api/v1/queue/default/cancel_by_batch_ids', { + body: JSON.stringify({ batch_ids: [pending.batch_id] }), + method: 'PUT', + }); + + assert.ok(canceled.canceled >= 1); + assert.equal((await getJson(backend, `/api/v1/queue/default/i/${pending.item_id}`)).status, 'canceled'); + if (secondRerun.batch_id !== pending.batch_id) { + assert.equal((await getJson(backend, `/api/v1/queue/default/i/${secondRerun.item_id}`)).status, 'pending'); + } + + const pruned = await getJson(backend, '/api/v1/queue/default/prune', { method: 'PUT' }); + const remaining = await getJson(backend, '/api/v1/queue/default/list_all'); + + assert.ok(pruned.deleted >= 1); + assert.ok(remaining.every((item) => ['pending', 'waiting', 'in_progress'].includes(item.status))); + }); +}); + +test('queue chain rules: prune keeps finished children of an active root, and canceling a child cancels its chain', async () => { + await withRepresentativeBackend(async (backend) => { + const items = await getJson(backend, '/api/v1/queue/default/list_all'); + const pending = items.find((item) => item.status === 'pending'); + const completed = items.find((item) => item.status === 'completed'); + assert.ok(pending && completed, 'the representative queue has pending and completed items'); + + const nextId = Math.max(...items.map((item) => item.item_id)) + 1; + const chained = (item_id, root, status) => ({ + ...root, + item_id, + parent_item_id: root.item_id, + root_item_id: root.item_id, + status, + }); + const activeRoot = { ...pending, item_id: nextId, parent_item_id: null, root_item_id: null }; + const finishedChildOfActive = chained(nextId + 1, activeRoot, 'completed'); + const waitingChildOfActive = chained(nextId + 2, activeRoot, 'pending'); + const finishedChildOfFinished = chained(nextId + 3, completed, 'completed'); + await getJson(backend, '/__queue_items', { + body: JSON.stringify({ + items: [activeRoot, finishedChildOfActive, waitingChildOfActive, finishedChildOfFinished], + }), + method: 'POST', + }); + + await getJson(backend, '/api/v1/queue/default/prune', { method: 'PUT' }); + const afterPrune = new Set((await getJson(backend, '/api/v1/queue/default/list_all')).map((item) => item.item_id)); + + // Like the backend, a finished child stays while its root is still active; one under a finished root goes. + assert.ok(afterPrune.has(finishedChildOfActive.item_id)); + assert.ok(!afterPrune.has(finishedChildOfFinished.item_id)); + + await getJson(backend, `/api/v1/queue/default/i/${waitingChildOfActive.item_id}/cancel`, { method: 'PUT' }); + const status = async (itemId) => (await getJson(backend, `/api/v1/queue/default/i/${itemId}`)).status; + + assert.equal(await status(activeRoot.item_id), 'canceled'); + assert.equal(await status(waitingChildOfActive.item_id), 'canceled'); + assert.equal(await status(finishedChildOfActive.item_id), 'completed'); + assert.equal(await status(pending.item_id), 'pending'); + }); +}); + +test('runtime config PATCH keeps the other writable field and validates the queue history limit', async () => { + await withRepresentativeBackend(async (backend) => { + await getJson(backend, '/api/v1/app/runtime_config', { + body: JSON.stringify({ generation_devices: ['cpu'] }), + method: 'PATCH', + }); + const withHistory = await getJson(backend, '/api/v1/app/runtime_config', { + body: JSON.stringify({ max_queue_history: 0 }), + method: 'PATCH', + }); + + assert.deepEqual(withHistory.config, { generation_devices: ['cpu'], max_queue_history: 0 }); + + const invalid = await fetch(`${backend.origin}/api/v1/app/runtime_config`, { + body: JSON.stringify({ max_queue_history: -1 }), + headers: { 'Content-Type': 'application/json' }, + method: 'PATCH', + }); + + assert.equal(invalid.status, 422); + + const cache = await getJson(backend, '/api/v1/app/invocation_cache/disable', { method: 'PUT' }); + assert.equal(cache, null); + assert.equal((await getJson(backend, '/api/v1/app/invocation_cache/status')).enabled, false); + }); +}); diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 74836005032..b1bffe1362b 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -110,6 +110,8 @@ const STARTER_MODELS_RESPONSE = { starter_models: [STARTER_MODEL], }; +const DELIVERY_CLAIM_TTL_MS = 120_000; + const createState = (profile) => { const fixture = assertMockBackendFixture(createMockBackendFixture(profile)); @@ -137,6 +139,9 @@ const createState = (profile) => { intermediatesOperations: new Map(), intermediatesPreviews: new Map(), queueItems: new Map(fixture.queueItems.map((item) => [item.item_id, clone(item)])), + deliveryClaims: new Map(), + runtimeConfig: { generation_devices: 'auto', max_queue_history: null }, + invocationCache: { enabled: true, hits: 0, max_size: 1000, misses: 0, size: 0 }, videos: new Map(fixture.videos.map((video) => [video.video_name, clone(video)])), workflows: new Map(fixture.workflows.map((workflow) => [workflow.workflow_id, clone(workflow)])), nextWorkflowNumber: fixture.workflows.length + 1, @@ -791,6 +796,15 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, { ok: true, ...getProfileInfo(state) }); } + if (method === 'POST' && path === '/__queue_items') { + // Test seam: workflow-call chains only arise from execution, which the mock does not run. + const body = await readJsonBody(request); + for (const item of Array.isArray(body.items) ? body.items : []) { + state.queueItems.set(item.item_id, clone(item)); + } + return json(200, { ok: true }); + } + if (path === '/__faults') { if (method === 'POST') { const body = await readJsonBody(request); @@ -907,8 +921,45 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, [{ device: 'cpu', name: 'CPU' }]); } - if (method === 'GET' && path === '/api/v1/app/runtime_config') { - return json(200, { config: { generation_devices: 'auto' }, set_fields: [] }); + if (path === '/api/v1/app/runtime_config') { + if (method === 'PATCH') { + const changes = await readJsonBody(request); + + if ('generation_devices' in changes) { + state.runtimeConfig.generation_devices = changes.generation_devices; + } + if ('max_queue_history' in changes) { + const value = changes.max_queue_history; + + if (value !== null && !(Number.isInteger(value) && value >= 0)) { + return json(422, { detail: 'max_queue_history must be a non-negative integer or null' }); + } + state.runtimeConfig.max_queue_history = value; + } + } + if (method === 'GET' || method === 'PATCH') { + return json(200, { config: { ...state.runtimeConfig }, set_fields: [] }); + } + } + + if (path.startsWith('/api/v1/app/invocation_cache')) { + const cache = state.invocationCache; + + if (path === '/api/v1/app/invocation_cache/status' && method === 'GET') { + return json(200, { ...cache }); + } + if (path === '/api/v1/app/invocation_cache' && method === 'DELETE') { + cache.size = 0; + return json(200, null); + } + if (path === '/api/v1/app/invocation_cache/enable' && method === 'PUT') { + cache.enabled = true; + return json(200, null); + } + if (path === '/api/v1/app/invocation_cache/disable' && method === 'PUT') { + cache.enabled = false; + return json(200, null); + } } if (path.startsWith('/api/v1/app/external_providers')) { @@ -1145,6 +1196,59 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } } + const queueItemDeliveryMatch = /^\/api\/v1\/queue\/[^/]+\/i\/(\d+)\/delivery$/.exec(path); + if (queueItemDeliveryMatch && method === 'POST') { + // Like the backend: one claimant at a time stages an item's results until it confirms, releases, or its claim + // expires; a confirmed item stays delivered. + const itemId = Number(queueItemDeliveryMatch[1]); + if (!state.queueItems.has(itemId)) { + return json(404, { detail: 'Queue item not found' }); + } + const { action, claimant } = await readJsonBody(request); + const claim = state.deliveryClaims.get(itemId); + const now = Date.now(); + if (action === 'confirm') { + state.deliveryClaims.set(itemId, { claimant, claimedAt: now, isDelivered: true }); + return json(200, { status: 'delivered' }); + } + if (action === 'release') { + if (claim?.claimant === claimant && !claim.isDelivered) { + state.deliveryClaims.delete(itemId); + } + return json(200, { status: 'released' }); + } + if (claim?.isDelivered) { + return json(200, { status: 'delivered' }); + } + if (claim && claim.claimant !== claimant && now - claim.claimedAt < DELIVERY_CLAIM_TTL_MS) { + return json(200, { retry_after_ms: DELIVERY_CLAIM_TTL_MS - (now - claim.claimedAt), status: 'held' }); + } + state.deliveryClaims.set(itemId, { claimant, claimedAt: now, isDelivered: false }); + return json(200, { status: 'claimed' }); + } + + const queueItemCancelMatch = /^\/api\/v1\/queue\/[^/]+\/i\/(\d+)\/cancel$/.exec(path); + if (queueItemCancelMatch && method === 'PUT') { + const item = state.queueItems.get(Number(queueItemCancelMatch[1])); + + if (!item) { + return json(404, { detail: 'Queue item not found' }); + } + // Like the backend, canceling any item of a workflow-call chain cancels the whole chain. + const rootId = item.root_item_id ?? item.item_id; + for (const chainItem of state.queueItems.values()) { + if ( + (chainItem.root_item_id ?? chainItem.item_id) === rootId && + ['pending', 'waiting', 'in_progress'].includes(chainItem.status) + ) { + chainItem.status = 'canceled'; + chainItem.completed_at = timestamp(state); + chainItem.updated_at = chainItem.completed_at; + } + } + return json(200, item); + } + const queueMatch = /^\/api\/v1\/queue\/[^/]+\/(.+)$/.exec(path); if (queueMatch) { const action = queueMatch[1]; @@ -1174,6 +1278,39 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { scopedItems.filter((item) => ids.has(item.item_id)) ); } + if (action === 'item_summaries_by_ids') { + const body = await readJsonBody(request); + const ids = Array.isArray(body.item_ids) ? body.item_ids : []; + const itemsById = new Map(scopedItems.map((item) => [item.item_id, item])); + + return json( + 200, + ids + .map((itemId) => itemsById.get(itemId)) + .filter((item) => item !== undefined) + // SessionQueueItemSummary: an item without its session, workflow or lineage beyond the parent. + .map((item) => + Object.fromEntries( + [ + 'item_id', + 'created_at', + 'status', + 'device', + 'started_at', + 'completed_at', + 'origin', + 'destination', + 'batch_id', + 'user_id', + 'user_display_name', + 'user_email', + 'field_values', + 'parent_item_id', + ].map((key) => [key, item[key] ?? null]) + ) + ) + ); + } if (action === 'item_ids' || action === 'list') { const descending = url.searchParams.get('order_dir')?.toUpperCase() !== 'ASC'; const itemIds = scopedItems.map((item) => item.item_id).sort((left, right) => left - right); @@ -1197,12 +1334,79 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, { deleted: true }); } if (action === 'prune' && method === 'PUT') { + // Like the backend: a finished child stays while its root is still active. + const isTerminal = (item) => ['completed', 'failed', 'canceled'].includes(item?.status); + const prunable = [...state.queueItems.values()].filter( + (item) => + isTerminal(item) && + (item.root_item_id === null || + item.root_item_id === undefined || + !state.queueItems.has(item.root_item_id) || + isTerminal(state.queueItems.get(item.root_item_id))) + ); + + for (const item of prunable) { + state.queueItems.delete(item.item_id); + } + return json(200, { deleted: prunable.length }); + } + if (action === 'cancel_by_batch_ids' && method === 'PUT') { + const body = await readJsonBody(request); + const batchIds = new Set(Array.isArray(body.batch_ids) ? body.batch_ids : []); + let canceled = 0; + for (const item of state.queueItems.values()) { - if (item.status === 'completed' || item.status === 'failed' || item.status === 'canceled') { - state.queueItems.delete(item.item_id); + if (batchIds.has(item.batch_id) && ['pending', 'waiting', 'in_progress'].includes(item.status)) { + item.status = 'canceled'; + item.completed_at = timestamp(state); + item.updated_at = item.completed_at; + canceled += 1; } } - return json(200, { deleted: true }); + return json(200, { canceled }); + } + if (action === 'retry_items_by_id' && method === 'PUT') { + // Like the backend: a failed or canceled item retries its failed or canceled root, once, as a new pending + // item with a fresh session; the result names the retried roots and pairs each with its rerun. + const itemIds = await readJsonBody(request, []); + const retriedItems = []; + const seenRoots = new Set(); + const isRetryable = (item) => ['failed', 'canceled'].includes(item?.status); + + for (const itemId of Array.isArray(itemIds) ? itemIds : []) { + const item = state.queueItems.get(itemId); + const root = state.queueItems.get(item?.root_item_id ?? item?.item_id); + + if (!isRetryable(item) || !isRetryable(root) || seenRoots.has(root.item_id)) { + continue; + } + seenRoots.add(root.item_id); + + const nextId = Math.max(0, ...state.queueItems.keys()) + 1; + const now = timestamp(state); + + state.queueItems.set(nextId, { + ...clone(root), + completed_at: null, + created_at: now, + error_message: null, + error_traceback: null, + error_type: null, + item_id: nextId, + retried_from_item_id: root.retried_from_item_id ?? root.item_id, + session: { prepared_source_mapping: {}, results: {} }, + session_id: `${root.session_id}-retry-${nextId}`, + started_at: null, + status: 'pending', + updated_at: now, + }); + retriedItems.push({ item_id: nextId, source_item_id: root.item_id }); + } + return json(200, { + queue_id: 'default', + retried_item_ids: retriedItems.map((item) => item.source_item_id), + retried_items: retriedItems, + }); } return json(200, null); diff --git a/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx b/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx index c16b1cf2e91..d8f6ccb0b1c 100644 --- a/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx @@ -1,4 +1,5 @@ import { invalidateGallery } from '@features/gallery/queries'; +import { getAuthSession } from '@features/identity'; import { modelLoadActivitySink } from '@features/models'; import { nodeExecutionStore } from '@features/nodes'; import { createProductionQueueRuntime, createProductionQueueReceiptAcknowledgements } from '@features/queue'; @@ -29,6 +30,8 @@ export const QueueRuntimeAdapter = () => { useMountEffect(() => { const owner = captureAccountScope(); + // Changing users remounts the workbench route, so the session read here stays the runtime's viewer. + const session = getAuthSession(); let isDisposed = false; let journal: QueueRunJournal | undefined; let recallCache: QueueRecallCache | undefined; @@ -149,6 +152,7 @@ export const QueueRuntimeAdapter = () => { locks: createQueueRunLockPort(owner.storageSuffix), modelLoads: modelLoadActivitySink, nodeExecution: nodeExecutionStore, + viewerUserId: session.multiuserEnabled ? (session.user?.user_id ?? null) : null, }); runtime.start(); }; diff --git a/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx b/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx index 2f3b596596d..2687793861a 100644 --- a/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx @@ -1,5 +1,6 @@ import { useAuthSession, useCapabilities } from '@features/identity'; import { getQueueItemAccess, QueueUiProvider, type QueueUiAdapter } from '@features/queue/react'; +import { areArraysEqual } from '@platform/state/selectors'; import { useWorkbenchPreferences } from '@workbench/settings/store'; import { useNotify } from '@workbench/useNotify'; import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget'; @@ -12,11 +13,15 @@ const QueueItemActions = lazy(() => importQueueItemActions().then((module) => ({ export const QueueUiAdapterProvider = ({ children }: { children: ReactNode }) => { const notify = useNotify(); const activeProjectId = useActiveProjectSelector((project) => project.id); - const { canManageModels } = useCapabilities(); + const { canManageAppConfig, canManageModels } = useCapabilities(); const session = useAuthSession(); const { queueJobsScope } = useWorkbenchPreferences(); const openWorkbenchWidget = useOpenWorkbenchWidget(); const isConnected = useWorkbenchSelector((snapshot) => snapshot.backendConnection.status === 'connected'); + const openProjectIds = useWorkbenchSelector( + (snapshot) => snapshot.projects.map((project) => project.id), + areArraysEqual + ); const adapter = useMemo(() => { const viewer = { currentUserId: session.user?.user_id ?? null, @@ -27,16 +32,28 @@ export const QueueUiAdapterProvider = ({ children }: { children: ReactNode }) => return { activeProjectId, ItemActions: QueueItemActions, + canManageInvocationCache: canManageAppConfig, canManageProcessor: canManageModels, canManageItem: (item) => getQueueItemAccess(item, viewer).canManage, canViewItemDetails: (item) => getQueueItemAccess(item, viewer).canViewDetails, isConnected, + isProjectOpen: (projectId) => openProjectIds.includes(projectId), notify, openQueue: () => openWorkbenchWidget('queue'), preloadItemActions: () => void importQueueItemActions(), queueJobsScope, }; - }, [activeProjectId, canManageModels, isConnected, notify, openWorkbenchWidget, queueJobsScope, session]); + }, [ + activeProjectId, + canManageAppConfig, + canManageModels, + isConnected, + notify, + openProjectIds, + openWorkbenchWidget, + queueJobsScope, + session, + ]); return {children}; }; diff --git a/invokeai/frontend/webv2/src/features/gallery/core/items.ts b/invokeai/frontend/webv2/src/features/gallery/core/items.ts index 082c32a1104..ce4b7b0b35d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/items.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/items.ts @@ -34,6 +34,8 @@ export interface GalleryImageItem extends GalleryItemBase { export interface GalleryVideoItem extends GalleryItemBase { durationSeconds: number; fps?: number; + /** Whether the video embeds the workflow that made it. Video records say; the mixed-media listing does not. */ + hasWorkflow?: boolean; kind: 'video'; /** `audio_upload` identifies audio converted into a waveform video rather than footage. */ mediaOrigin?: string; diff --git a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts index 8df401ff536..388168dba30 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts @@ -114,6 +114,7 @@ interface BackendVideoDTO { created_at: string; duration: number; fps?: number | null; + has_workflow?: boolean; height: number; is_intermediate: boolean; media_origin?: string | null; @@ -310,6 +311,7 @@ const mapVideo = (video: BackendVideoDTO): GalleryVideoItem => { durationSeconds: video.duration, ...(video.fps === null || video.fps === undefined ? {} : { fps: video.fps }), fullUrl: absolutizeApiUrl(video.video_url), + ...(video.has_workflow === undefined ? {} : { hasWorkflow: video.has_workflow }), height: video.height, isIntermediate: video.is_intermediate, kind: 'video', diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queries.ts b/invokeai/frontend/webv2/src/features/gallery/data/queries.ts index cc69a85f427..cc4cc14ee8f 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queries.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queries.ts @@ -201,6 +201,9 @@ export const galleryKeys = { itemNames: (owner: AccountScope, filter: CanonicalGalleryItemsFilter) => [...galleryKeys.itemNamesForAccount(owner), filter] as const, imageIndexAvailability: (owner: AccountScope) => [...galleryKeys.all, 'image-index', getAccountKey(owner)] as const, + /** What a video's context menu can offer (recall, embedded workflow), read from its record when opened. */ + videoMenuCapabilities: (owner: AccountScope, videoName: string | null) => + [...galleryKeys.itemsRoot(), 'menu-capabilities', getAccountKey(owner), videoName] as const, }; const galleryItemNamesOptionsForOwner = (owner: AccountScope, filter: CanonicalGalleryItemsFilter) => diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx index d51c44a0fc7..0b72938b623 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx @@ -266,6 +266,7 @@ const dogs = (count: number, from = 0) => * measures the failure notice against the viewport and the loaded tiles. */ const scrollToLoadMoreFailure = async () => { + await waitFor(() => expect(gridViewport()).not.toBeNull()); const viewport = gridViewport()!; const scrollToEnd = () => { viewport.scrollTop = viewport.scrollHeight; diff --git a/invokeai/frontend/webv2/src/features/generation/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index 7b53b1a09d8..03048c0c02b 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -24,6 +24,7 @@ export { MAX_FLUX_DYPE_EXPONENT, MAX_FLUX_DYPE_SCALE, MAX_HIDIFFUSION_RATIO, + MAX_KREA2_SEED_VARIANCE_STRENGTH, MAX_NEGATIVE_PROMPT_HEIGHT_PX, MAX_POSITIVE_PROMPT_HEIGHT_PX, MAX_TIMESTEP_SHIFT, diff --git a/invokeai/frontend/webv2/src/features/queue/README.md b/invokeai/frontend/webv2/src/features/queue/README.md index a2273512f05..851788d67e6 100644 --- a/invokeai/frontend/webv2/src/features/queue/README.md +++ b/invokeai/frontend/webv2/src/features/queue/README.md @@ -5,3 +5,5 @@ Paths are relative to the webv2 `src/` directory. Queue's private `QueueBackendPort` is adapter seam; public callers use domain reads, commands, and production runtime factories from `@features/queue`. Private snake_case HTTP DTOs map explicitly to camelCase Queue read models before crossing that interface. TanStack Query owns global backend queue read model (reads and hydrates only a bounded recent window) and a status-only read for count summaries such as Home; non-React runtimes own reconciliation and realtime orchestration. Persisted per-project queue history stays in Workbench aggregate. Queue exposes board-independent active backend sessions, including preparation and result settlement. Gallery progress section includes every unfinished batch slot, queued work too; Preview follows executing and settling sessions. Both use gallery-destination projections of active project's queue; eligibility is presentation policy, separate from discovery. Workbench owns transient overview/pinned follow selection for its mounted lifetime, clears pins on project changes or when following stops, and never persists pins or preview frames. Live sessions take no part in board counts, saved-image selection, or navigation. Saved outputs keep their actual backend board destinations; automatic result selection does not change browsed board or its paging window. + +Retry re-runs failed or canceled backend items under new ids; the run owning them reopens around the reruns so its captured destination receives their results. A rerun no open run adopts reaches the Gallery, or the current Canvas of a tab with its project open. Canvas reruns are staged once across every tab and browser of the account through a delivery claim the server keeps per queue item (`POST queue/{id}/i/{item_id}/delivery`): a claimant claims, stages, then confirms; a claim neither confirmed nor released lapses after two minutes. A reopened run claims its reruns as `run:` once its tab holds the run, renews them while it lasts, and gives back what it did not stage when the tab lets the run go, so a reload restores the same claimant and a run never stages a rerun another tab staged first. A tab the browser freezes stops renewing; another tab then stages the rerun at its own current bbox. A tab staging a rerun no run adopted claims as itself, after waiting for any tab of the browser that still tracks the run, and asks again while another claimant holds the rerun. A server without the endpoint lets every tab stage. diff --git a/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts b/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts index 27c801a045a..eb85fe37158 100644 --- a/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts +++ b/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts @@ -20,6 +20,8 @@ export interface QueueSubmissionSnapshot { filterIntermediateResults: boolean; resultNodeIds?: readonly string[]; presentation: QueueSubmissionPresentation; + /** Set by Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; submittedAt: string; } @@ -34,6 +36,8 @@ export interface QueueItem { backendItemIds?: number[]; completedBackendItemIds?: number[]; cancelledBackendItemIds?: number[]; + /** Backend items a retry reopened this run away from; retrying another of them later still returns here. */ + retriedBackendItemIds?: number[]; backendBatchId?: string; error?: string; resultImages?: QueueResultImage[]; diff --git a/invokeai/frontend/webv2/src/features/queue/core/submissionRules.ts b/invokeai/frontend/webv2/src/features/queue/core/submissionRules.ts index a4b5ee82e1c..6e005c186f7 100644 --- a/invokeai/frontend/webv2/src/features/queue/core/submissionRules.ts +++ b/invokeai/frontend/webv2/src/features/queue/core/submissionRules.ts @@ -1,6 +1,21 @@ import type { QueueItem } from '@features/queue/core/historyTypes'; import type { QueueSourceId } from '@features/queue/core/types'; +/** The graph nodes whose outputs are a source's results; Workflow runs deliver every image they produce. */ +export const QUEUE_SOURCE_RESULT_NODE_IDS: Readonly>> = { + canvas: ['canvas_output'], + generate: ['canvas_output'], + upscale: ['upscale_output'], + video: ['video_output'], +}; + +/** The snapshot fields that limit a source's queued run to its result nodes. */ +export const getQueueSourceResultSelection = (sourceId: QueueSourceId): { resultNodeIds?: string[] } => { + const resultNodeIds = QUEUE_SOURCE_RESULT_NODE_IDS[sourceId]; + + return resultNodeIds ? { resultNodeIds: [...resultNodeIds] } : {}; +}; + /** Source kinds whose pending queue items the runtime enqueues to the backend. */ export const BACKEND_SUBMITTABLE_SOURCE_IDS = [ 'generate', diff --git a/invokeai/frontend/webv2/src/features/queue/core/types.ts b/invokeai/frontend/webv2/src/features/queue/core/types.ts index 685e0071531..e2dd92e53b6 100644 --- a/invokeai/frontend/webv2/src/features/queue/core/types.ts +++ b/invokeai/frontend/webv2/src/features/queue/core/types.ts @@ -43,6 +43,8 @@ interface QueueEnqueueRequestBase { batchCount: number; destination: QueueResultDestination; graph: QueueBackendGraph; + /** Ask the backend to run the batch ahead of the user's other pending work (Invoke Front). */ + prepend?: boolean; projectId: string; sourceQueueItemId: string; } @@ -189,6 +191,8 @@ export interface QueueItemReadModel { fieldValues?: QueueNodeFieldValue[] | null; id: number; origin?: string | null; + /** The workflow-call parent this item runs for; null or absent on root items. */ + parentItemId?: number | null; resultImageNames: string[]; retriedFromItemId?: number | null; sessionId: string; @@ -200,6 +204,26 @@ export interface QueueItemReadModel { userId?: string; } +/** + * The saved execution data of one queue item, read on demand for diagnostics. It carries the session graph the + * backend actually ran, its per-node results and the embedded workflow, which the live read model leaves out. + */ +export interface QueueItemDetailReadModel { + errorMessage: string | null; + errorTraceback: string | null; + errorType: string | null; + /** The compiled graph the session executed; empty when the backend redacted another user's item. */ + graph: Record; + id: number; + priority: number | null; + /** Node results keyed by executed node id; empty until the session produced output. */ + results: Record; + sessionId: string; + status: QueueItemStatus; + /** The workflow embedded with the batch, when one was submitted. */ + workflow: Record | null; +} + export interface QueueCounts { batchId?: string | null; canceled: number; @@ -281,13 +305,19 @@ export interface QueueResultVideoOptions extends QueueResultImageOptions { /** User-facing Queue commands; transport and adapter details stay private. */ export interface QueueFeatureCommands { + /** Cancels every unfinished item of one batch; resolves with how many the backend canceled. */ + cancelBatch(batchId: string): Promise; cancelCurrentItem(): Promise; cancelItem(itemId: number): Promise; cancelScopedItems(scope?: QueueQueryScope, options?: { keepCurrent?: boolean }): Promise; clearFailedItems(scope?: QueueQueryScope): Promise; + /** Removes completed, canceled and failed items; pending and running work is kept. */ + clearFinishedItems(scope?: QueueQueryScope): Promise; clearItems(scope?: QueueQueryScope): Promise; pauseProcessor(): Promise; resumeProcessor(): Promise; + /** Re-queues failed or canceled root items from their saved sessions; resolves with the ids actually retried. */ + retryItems(itemIds: number[]): Promise; } /** Own command transport and realtime events together so runtimes do not assemble HTTP/socket plumbing. */ @@ -306,8 +336,22 @@ export interface QueueProgressPreviewPayload { image: { width: number; height: number; dataURL: string } | null; } +/** Who stages a retried item's results, as the server agreed it across every browser and tab of the account. */ +export type QueueItemDeliveryClaim = + | { kind: 'claimed' | 'delivered' | 'released' } + | { kind: 'held'; retryInMs: number }; + export interface QueueBackendPort extends QueueFeatureCommands { acknowledgeEnqueue?(projectId: string, sourceQueueItemId: string): Promise; + /** + * Claim, confirm or release the delivery of an item's results. A claim is held until its claimant confirms or + * releases it, or until it expires; an item once confirmed is delivered for good. + */ + claimItemDelivery( + itemId: number, + action: 'claim' | 'confirm' | 'release', + claimant: string + ): Promise; getEnqueueReceipt?(projectId: string, sourceQueueItemId: string): Promise; /** The latest preview frame of each of the user's running items (`GET queue/{id}/previews`). */ readProgressPreviews?(signal?: AbortSignal): Promise; @@ -338,7 +382,9 @@ export interface QueueBackendPort extends QueueFeatureCommands { readItemsById(itemIds: number[], signal?: AbortSignal): Promise; readNext(scope?: QueueQueryScope, signal?: AbortSignal): Promise; readStatus(scope?: QueueQueryScope, signal?: AbortSignal): Promise; - retryItems(itemIds: number[]): Promise; + /** The saved execution data of one item, read on demand. */ + readItemDetail(itemId: number, signal?: AbortSignal): Promise; + retryItems(itemIds: number[]): Promise; emit(event: string, payload: unknown): void; on(event: string, handler: (payload: never) => void): () => void; onConnectionChange(handler: (status: QueueConnectionStatus, error?: string) => void): () => void; diff --git a/invokeai/frontend/webv2/src/features/queue/data/events.ts b/invokeai/frontend/webv2/src/features/queue/data/events.ts index 1554fc30cbc..45aed671243 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/events.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/events.ts @@ -63,6 +63,8 @@ export interface QueueItemsRetriedEvent { queue_id: string; retried_item_ids: number[]; retried_item_ids_by_user: Record; + /** Each retried root paired with the item that re-runs it; absent from servers that predate the pairing. */ + retried_items?: { item_id: number; source_item_id: number }[]; timestamp: number; user_ids: string[]; } diff --git a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts index fa917e0adea..a0f55085900 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts @@ -9,7 +9,7 @@ vi.mock('@platform/transport/http', () => ({ getApiErrorMessage: (_error: unknown, fallback: string) => fallback, })); -const { getGenerationDevicesSnapshot, refreshGenerationDevices, updateGenerationDevices } = +const { getGenerationDevicesSnapshot, refreshGenerationDevices, updateGenerationDevices, updateMaxQueueHistory } = await import('./generationDevicesStore'); const options = [ @@ -83,4 +83,79 @@ describe('generationDevicesStore', () => { await expect(updateGenerationDevices(['cuda:9'])).rejects.toThrow('422 invalid device'); }); + + it('reads the queue history limit, telling "keep everything" apart from "not readable"', async () => { + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') + ? Promise.resolve(options) + : Promise.resolve({ config: { generation_devices: 'auto', max_queue_history: 0 }, set_fields: [] }) + ); + await refreshGenerationDevices(); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBe(0); + + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') + ? Promise.resolve(options) + : Promise.resolve({ config: { generation_devices: 'auto', max_queue_history: null }, set_fields: [] }) + ); + await refreshGenerationDevices(); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeNull(); + + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') ? Promise.resolve(options) : Promise.reject(new Error('403')) + ); + await refreshGenerationDevices(); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeUndefined(); + + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') + ? Promise.resolve(options) + : Promise.resolve({ config: { generation_devices: 'auto', max_queue_history: -3 }, set_fields: [] }) + ); + await refreshGenerationDevices(); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeUndefined(); + }); + + it('still reads the queue history limit when the device listing fails', async () => { + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') + ? Promise.reject(new Error('500')) + : Promise.resolve({ config: { generation_devices: 'auto', max_queue_history: 12 }, set_fields: [] }) + ); + + await refreshGenerationDevices(); + + expect(getGenerationDevicesSnapshot()).toMatchObject({ loadState: 'error', maxQueueHistory: 12 }); + }); + + it('PATCHes only the queue history limit so the device selection is left untouched', async () => { + mocks.apiFetchJson.mockResolvedValue({ + config: { generation_devices: ['cuda:1'], max_queue_history: 25 }, + set_fields: ['generation_devices', 'max_queue_history'], + }); + + await updateMaxQueueHistory(25); + + expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/app/runtime_config', { + body: JSON.stringify({ max_queue_history: 25 }), + method: 'PATCH', + signal: expect.anything(), + }); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBe(25); + + mocks.apiFetchJson.mockResolvedValue({ config: { generation_devices: ['cuda:1'], max_queue_history: null } }); + await updateMaxQueueHistory(null); + const lastInit = mocks.apiFetchJson.mock.lastCall?.[1] as RequestInit | undefined; + expect(JSON.parse(String(lastInit?.body))).toEqual({ max_queue_history: null }); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeNull(); + + // The server's answer is what is stored, even when it differs from the request. + mocks.apiFetchJson.mockResolvedValue({ config: { generation_devices: ['cuda:1'], max_queue_history: null } }); + await updateMaxQueueHistory(40); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeNull(); + + mocks.apiFetchJson.mockResolvedValue({ detail: 'unexpected' }); + await updateMaxQueueHistory(40); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeUndefined(); + }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts index 326c21c1173..ff4bf29d4dc 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts @@ -10,18 +10,25 @@ import { createExternalStore } from '@platform/state/externalStore'; import { apiFetchJson, getApiErrorMessage } from '@platform/transport/http'; /** - * Read available accelerators and server-wide generation_devices without polling; configuration takes effect only - * after restart and has no socket updates. + * Read available accelerators and the server's writable runtime settings (generation_devices, max_queue_history) + * without polling; configuration takes effect only after restart and has no socket updates. */ /** `auto` uses every available accelerator; an explicit list pins generation to those devices. */ export type GenerationDevicesSetting = 'auto' | string[]; +/** + * How many finished (completed, failed, canceled) items the server keeps at startup: a count, `null` when the + * server prunes nothing, or `undefined` while unknown (not loaded, or the reader is not an administrator). + */ +export type MaxQueueHistorySetting = number | null; + export interface GenerationDevicesSnapshot { /** Every installed generation device, in backend order. Empty until loaded. */ options: GenerationDeviceOption[]; /** The configured setting, or null while unknown. */ setting: GenerationDevicesSetting | null; + maxQueueHistory: MaxQueueHistorySetting | undefined; loadState: 'idle' | 'loading' | 'loaded' | 'error'; error: string | null; } @@ -29,6 +36,7 @@ export interface GenerationDevicesSnapshot { const EMPTY_SNAPSHOT: GenerationDevicesSnapshot = { error: null, loadState: 'idle', + maxQueueHistory: undefined, options: [], setting: null, }; @@ -52,6 +60,7 @@ registerAccountOwnedResource({ interface RuntimeConfigResponse { config?: { generation_devices?: GenerationDevicesSetting | null; + max_queue_history?: number | null; } | null; set_fields?: string[]; } @@ -59,6 +68,16 @@ interface RuntimeConfigResponse { const readGenerationDevices = (response: RuntimeConfigResponse | null): GenerationDevicesSetting | null => response?.config?.generation_devices ?? null; +/** + * Distinguishes "the server keeps everything" (`null`) from "the config was not readable" (`undefined`). A value the + * server should never send is unreadable too: shown as "keep all" it would invite saving over the real limit. + */ +const readMaxQueueHistory = (response: RuntimeConfigResponse | null): MaxQueueHistorySetting | undefined => { + const value = response?.config?.max_queue_history; + + return value === null || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) ? value : undefined; +}; + /** * Non-admins can read device options but not runtime config; return a null setting without failing the read-only * view. @@ -74,9 +93,13 @@ export const refreshGenerationDevices = (): Promise => { store.patchSnapshot({ loadState: 'loading' }); } + // The runtime config is read on its own: a failed device listing must not hide the queue history setting. + const runtimeConfigRead = apiFetchJson('/api/v1/app/runtime_config', { + signal: owner.signal, + }).catch(() => null); const refresh = Promise.all([ apiFetchJson('/api/v1/app/generation_device_options', { signal: owner.signal }), - apiFetchJson('/api/v1/app/runtime_config', { signal: owner.signal }).catch(() => null), + runtimeConfigRead, ]) .then(([options, runtimeConfig]) => { if (!isAccountScopeCurrent(owner)) { @@ -86,11 +109,13 @@ export const refreshGenerationDevices = (): Promise => { store.patchSnapshot({ error: null, loadState: 'loaded', + maxQueueHistory: readMaxQueueHistory(runtimeConfig), options, setting: readGenerationDevices(runtimeConfig), }); }) - .catch((error: unknown) => { + .catch(async (error: unknown) => { + const runtimeConfig = await runtimeConfigRead; if (!isAccountScopeCurrent(owner)) { return; } @@ -98,6 +123,7 @@ export const refreshGenerationDevices = (): Promise => { store.patchSnapshot({ error: getApiErrorMessage(error, 'Failed to load generation devices'), loadState: 'error', + maxQueueHistory: readMaxQueueHistory(runtimeConfig), }); }) .finally(() => { @@ -126,6 +152,25 @@ export const updateGenerationDevices = async (setting: GenerationDevicesSetting) store.patchSnapshot({ error: null, setting: readGenerationDevices(runtimeConfig) ?? setting }); }; +/** + * Sends only `max_queue_history`, so the PATCH leaves generation devices alone; `null` clears the limit. Takes + * effect at the next startup, when the server prunes its history. The stored value is what the server answers, not + * what was asked; an unreadable answer leaves the setting unknown. + */ +export const updateMaxQueueHistory = async (value: MaxQueueHistorySetting): Promise => { + const owner = captureAccountScope(); + + const runtimeConfig = await apiFetchJson('/api/v1/app/runtime_config', { + body: JSON.stringify({ max_queue_history: value }), + method: 'PATCH', + signal: owner.signal, + }); + + assertAccountScopeCurrent(owner); + + store.patchSnapshot({ maxQueueHistory: readMaxQueueHistory(runtimeConfig) }); +}; + export const getGenerationDevicesSnapshot = (): GenerationDevicesSnapshot => store.getSnapshot(); export const useGenerationDevices = (): GenerationDevicesSnapshot => store.useSnapshot(); diff --git a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts new file mode 100644 index 00000000000..55b04847aea --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const server = vi.hoisted(() => ({ claimQueueItemDelivery: vi.fn(), retryItemsById: vi.fn() })); +vi.mock('./serverApi', async (importOriginal) => ({ ...(await importOriginal()), ...server })); + +import { queueBackend } from './httpRealtimeQueueBackend'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('queueBackend retries', () => { + it("hands the tab's own retry answer to retry listeners, so adoption does not hang on the socket", async () => { + const pairs = [{ item_id: 99, source_item_id: 41 }]; + server.retryItemsById.mockResolvedValue({ queue_id: 'default', retried_item_ids: [41], retried_items: pairs }); + const onRetried = vi.fn(); + const onOther = vi.fn(); + const detach = queueBackend.on('queue_items_retried', onRetried); + const detachOther = queueBackend.on('queue_item_status_changed', onOther); + + await expect(queueBackend.retryItems([41])).resolves.toEqual([41]); + expect(onRetried).toHaveBeenCalledWith( + expect.objectContaining({ queue_id: 'default', retried_items: pairs, user_ids: [] }) + ); + expect(onOther).not.toHaveBeenCalled(); + + detach(); + detachOther(); + await queueBackend.retryItems([41]); + expect(onRetried).toHaveBeenCalledTimes(1); + }); + + it('announces nothing for a server that does not pair retries', async () => { + server.retryItemsById.mockResolvedValue({ queue_id: 'default', retried_item_ids: [41] }); + const onRetried = vi.fn(); + const detach = queueBackend.on('queue_items_retried', onRetried); + + await queueBackend.retryItems([41]); + + expect(onRetried).not.toHaveBeenCalled(); + detach(); + }); +}); + +describe('queueBackend delivery claims', () => { + it("passes the server's answer on, with how long a held claim keeps the item", async () => { + server.claimQueueItemDelivery + .mockResolvedValueOnce({ retry_after_ms: null, status: 'claimed' }) + .mockResolvedValueOnce({ retry_after_ms: 1_500, status: 'held' }) + .mockResolvedValueOnce({ retry_after_ms: null, status: 'held' }) + .mockResolvedValueOnce({ status: 'delivered' }); + + await expect(queueBackend.claimItemDelivery(99, 'claim', 'tab:a')).resolves.toEqual({ kind: 'claimed' }); + await expect(queueBackend.claimItemDelivery(99, 'claim', 'tab:b')).resolves.toEqual({ + kind: 'held', + retryInMs: 1_500, + }); + // A held answer without a time is asked again at once rather than never. + await expect(queueBackend.claimItemDelivery(99, 'claim', 'tab:b')).resolves.toEqual({ kind: 'held', retryInMs: 1 }); + await expect(queueBackend.claimItemDelivery(99, 'confirm', 'tab:a')).resolves.toEqual({ kind: 'delivered' }); + expect(server.claimQueueItemDelivery).toHaveBeenCalledWith(99, 'confirm', 'tab:a'); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts index 61901ba45d7..104264bcbb6 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts @@ -1,15 +1,24 @@ import type { QueueBackendPort } from '@features/queue/core/types'; +import type { QueueItemsRetriedEvent } from '@features/queue/data/events'; import { socketHub } from '@platform/transport/socketHub'; -import { mapQueueBackendItemDTO, mapQueueItemDTO, mapQueueItemIdsDTO, mapQueueStatusDTO } from './mappers'; +import { + mapQueueBackendItemDTO, + mapQueueItemDetailDTO, + mapQueueItemDTO, + mapQueueItemIdsDTO, + mapQueueStatusDTO, +} from './mappers'; import { cancelByBatchIds, cancelCurrentQueueItem, + claimQueueItemDelivery, cancelQueueItem, cancelQueueItems, cancelScopedQueueItems, clearFailedQueueItems, + clearFinishedQueueItems, clearScopedQueue, getCurrentQueueItem, getNextQueueItem, @@ -33,9 +42,16 @@ import { getResultVideos, } from './submissionApi'; +/** + * This tab's own retry answers, delivered like the socket's `queue_items_retried` so a rerun is adopted even when + * the socket misses that event; handlers ignore the pairs they have already seen. + */ +const ownRetryHandlers = new Set<(payload: never) => void>(); + /** Production adapter for the queue backend port. */ export const queueBackend: QueueBackendPort = { acknowledgeEnqueue: acknowledgeQueueEnqueue, + cancelBatch: async (batchId) => (await cancelByBatchIds([batchId])).canceled, cancelCurrentItem: cancelCurrentQueueItem, cancelQueueItems, cancelQueueItemsByBatchIds: async (batchIds) => { @@ -46,6 +62,7 @@ export const queueBackend: QueueBackendPort = { }, cancelScopedItems: cancelScopedQueueItems, clearFailedItems: clearFailedQueueItems, + clearFinishedItems: clearFinishedQueueItems, clearItems: clearScopedQueue, emit: socketHub.emit, enqueueGenerate, @@ -56,11 +73,22 @@ export const queueBackend: QueueBackendPort = { getResultVideoNames, getResultVideos, listItems: async () => (await listAllQueueItems()).map(mapQueueBackendItemDTO), - on: socketHub.on, + on: (event, handler) => { + const detachSocket = socketHub.on(event, handler); + if (event !== 'queue_items_retried') { + return detachSocket; + } + ownRetryHandlers.add(handler); + return () => { + detachSocket(); + ownRetryHandlers.delete(handler); + }; + }, onConnectionChange: socketHub.onConnectionChange, pauseProcessor: async () => { await pauseQueueProcessor(); }, + readItemDetail: async (itemId, signal) => mapQueueItemDetailDTO(await getQueueItem(itemId, signal)), readCurrent: async (scope, signal) => { const item = await getCurrentQueueItem(scope, signal); @@ -76,7 +104,30 @@ export const queueBackend: QueueBackendPort = { return item ? mapQueueItemDTO(item) : null; }, readStatus: async (scope, signal) => mapQueueStatusDTO(await getQueueStatus(scope, signal)), - retryItems: retryItemsById, + claimItemDelivery: async (itemId, action, claimant) => { + const result = await claimQueueItemDelivery(itemId, action, claimant); + + return result.status === 'held' + ? { kind: 'held', retryInMs: Math.max(1, result.retry_after_ms ?? 0) } + : { kind: result.status }; + }, + retryItems: async (itemIds) => { + const result = await retryItemsById(itemIds); + if (result.retried_items?.length) { + const event: QueueItemsRetriedEvent = { + queue_id: result.queue_id, + retried_item_ids: result.retried_item_ids, + retried_item_ids_by_user: {}, + retried_items: result.retried_items, + timestamp: Date.now(), + user_ids: [], + }; + for (const handler of ownRetryHandlers) { + handler(event as never); + } + } + return result.retried_item_ids; + }, resumeProcessor: async () => { await resumeQueueProcessor(); }, diff --git a/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts b/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts new file mode 100644 index 00000000000..891b176b145 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts @@ -0,0 +1,75 @@ +import { + assertAccountScopeCurrent, + captureAccountScope, + isAccountScopeCurrent, +} from '@platform/state/accountLifecycle'; +import { queryOptions, type QueryClient } from '@tanstack/react-query'; + +import type { InvocationCacheStatusDTO } from './serverTypes'; + +import { queueKeys } from './queries'; +import { clearInvocationCache, getInvocationCacheStatus, setInvocationCacheEnabled } from './serverApi'; + +/** The invocation (node output) cache: hit/miss counts, size and whether it is enabled. Admin-only on the server. */ +export interface InvocationCacheStatus { + enabled: boolean; + hits: number; + maxSize: number; + misses: number; + size: number; +} + +const mapInvocationCacheStatus = (dto: InvocationCacheStatusDTO): InvocationCacheStatus => ({ + enabled: dto.enabled, + hits: dto.hits, + maxSize: dto.max_size, + misses: dto.misses, + size: dto.size, +}); + +export const invocationCacheKey = [...queueKeys.all, 'invocation-cache'] as const; + +/** + * Statistics have no socket stream. The key sits under the queue's so every queue refresh (status changes, enqueue, + * cancel) also refetches them, which is when hit and miss counts move; cache commands invalidate them directly. + */ +export const invocationCacheStatusOptions = () => + (() => { + const owner = captureAccountScope(); + + return queryOptions({ + queryFn: async ({ signal }): Promise => { + const status = await getInvocationCacheStatus(AbortSignal.any([signal, owner.signal])); + + assertAccountScopeCurrent(owner); + + return mapInvocationCacheStatus(status); + }, + queryKey: invocationCacheKey, + staleTime: 5_000, + }); + })(); + +/** Resolves false when the account changed meanwhile: the outcome no longer belongs to anyone who could see it. */ +export const runInvocationCacheCommand = async ( + queryClient: QueryClient, + command: 'clear' | 'disable' | 'enable' +): Promise => { + const owner = captureAccountScope(); + + try { + await (command === 'clear' + ? clearInvocationCache(owner.signal) + : setInvocationCacheEnabled(command === 'enable', owner.signal)); + } catch (error) { + if (!isAccountScopeCurrent(owner)) { + return false; + } + throw error; + } + if (!isAccountScopeCurrent(owner)) { + return false; + } + await queryClient.invalidateQueries({ queryKey: invocationCacheKey }); + return true; +}; diff --git a/invokeai/frontend/webv2/src/features/queue/data/mappers.test.ts b/invokeai/frontend/webv2/src/features/queue/data/mappers.test.ts index 67dc95e5c91..866b2770cd3 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/mappers.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/mappers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { mapQueueItemDTO, mapQueueItemIdsDTO, mapQueueStatusDTO } from './mappers'; +import { mapQueueItemDetailDTO, mapQueueItemDTO, mapQueueItemIdsDTO, mapQueueStatusDTO } from './mappers'; describe('queue transport mappers', () => { it('maps a backend queue item into the live read model', () => { @@ -117,4 +117,76 @@ describe('queue transport mappers', () => { }, }); }); + + it('maps the saved execution data of an item separately from the live row', () => { + const graph = { edges: [], id: 'graph-1', nodes: { noise: { id: 'noise', type: 'noise' } } }; + const results = { noise: { noise: { image_name: 'noise.png' } } }; + const workflow = { name: 'Portrait', nodes: [] }; + + expect( + mapQueueItemDetailDTO({ + batch_id: 'batch-1', + created_at: '2026-07-18T00:00:00Z', + error_message: 'boom', + error_traceback: 'Traceback…', + error_type: 'RuntimeError', + item_id: 7, + priority: 1, + session: { graph, results }, + session_id: 'session-1', + status: 'failed', + updated_at: '2026-07-18T01:00:00Z', + workflow, + }) + ).toEqual({ + errorMessage: 'boom', + errorTraceback: 'Traceback…', + errorType: 'RuntimeError', + graph, + id: 7, + priority: 1, + results, + sessionId: 'session-1', + status: 'failed', + workflow, + }); + }); + + it('reads an empty detail for a redacted or still-pending item', () => { + expect( + mapQueueItemDetailDTO({ + batch_id: 'redacted', + created_at: '2026-07-18T00:00:00Z', + item_id: 8, + session_id: 'redacted', + status: 'pending', + updated_at: '2026-07-18T00:00:00Z', + }) + ).toEqual({ + errorMessage: null, + errorTraceback: null, + errorType: null, + graph: {}, + id: 8, + priority: null, + results: {}, + sessionId: 'redacted', + status: 'pending', + workflow: null, + }); + }); + + it('keeps the workflow-call parent on the live row so child items can hide retry', () => { + expect( + mapQueueItemDTO({ + batch_id: 'batch-1', + created_at: '2026-07-18T00:00:00Z', + item_id: 9, + parent_item_id: 7, + session_id: 'session-9', + status: 'failed', + updated_at: '2026-07-18T00:00:00Z', + }).parentItemId + ).toBe(7); + }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/mappers.ts b/invokeai/frontend/webv2/src/features/queue/data/mappers.ts index 260f1f92175..f23edf73717 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/mappers.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/mappers.ts @@ -1,5 +1,6 @@ import type { QueueBackendItem, + QueueItemDetailReadModel, QueueItemIdsReadModel, QueueItemReadModel, QueueNodeFieldValue, @@ -40,6 +41,7 @@ export const mapQueueItemDTO = (dto: QueueServerItemDTO): QueueItemReadModel => : dto.field_values.map(mapNodeFieldValue), id: dto.item_id, origin: dto.origin, + parentItemId: dto.parent_item_id, resultImageNames: getResultImageNames(dto), retriedFromItemId: dto.retried_from_item_id, sessionId: dto.session_id, @@ -51,6 +53,20 @@ export const mapQueueItemDTO = (dto: QueueServerItemDTO): QueueItemReadModel => userId: dto.user_id, }); +/** The saved execution data alone; the live row keeps its own narrower mapping. */ +export const mapQueueItemDetailDTO = (dto: QueueServerItemDTO): QueueItemDetailReadModel => ({ + errorMessage: dto.error_message ?? null, + errorTraceback: dto.error_traceback ?? null, + errorType: dto.error_type ?? null, + graph: dto.session?.graph ?? {}, + id: dto.item_id, + priority: dto.priority ?? null, + results: dto.session?.results ?? {}, + sessionId: dto.session_id, + status: dto.status, + workflow: dto.workflow ?? null, +}); + export const mapQueueBackendItemDTO = (dto: QueueServerItemDTO): QueueBackendItem => ({ batchId: dto.batch_id, destination: dto.destination, diff --git a/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts b/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts index c3c9e548bd2..ff9518520ad 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts @@ -1,4 +1,4 @@ -import type { QueueBackendPort } from '@features/queue/core/types'; +import type { QueueBackendPort, QueueItemDetailReadModel } from '@features/queue/core/types'; import { accountLifecycle } from '@platform/state/accountLifecycle'; import { QueryClient } from '@tanstack/react-query'; @@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { queueBackend } from './httpRealtimeQueueBackend'; import { - invalidateQueueReadModels, + invalidateQueueQueries, + queueItemDetailOptions, QUEUE_RECENT_WINDOW, queueKeys, queueReadModelOptions, @@ -18,6 +19,9 @@ const createBackend = (): QueueBackendPort => ({ cancelCurrentItem: vi.fn(), cancelQueueItems: vi.fn(), cancelQueueItemsByBatchIds: vi.fn(), + cancelBatch: vi.fn(), + clearFinishedItems: vi.fn(), + readItemDetail: vi.fn(), cancelItem: vi.fn(), cancelScopedItems: vi.fn(), clearFailedItems: vi.fn(), @@ -54,6 +58,7 @@ const createBackend = (): QueueBackendPort => ({ }), retryItems: vi.fn(), resumeProcessor: vi.fn(), + claimItemDelivery: vi.fn(), }); describe('queue queries', () => { @@ -90,12 +95,27 @@ describe('queue queries', () => { await client.fetchQuery(queueReadModelOptions(backend, scope)); await client.fetchQuery(queueStatusOptions(backend, {})); - await invalidateQueueReadModels(client); + await invalidateQueueQueries(client); expect(client.getQueryState(queueKeys.readModel(scope))?.isInvalidated).toBe(true); expect(client.getQueryState(queueKeys.status({}))?.isInvalidated).toBe(true); }); + it("re-reads an open item's details only while the item can still change", async () => { + const backend = createBackend(); + const client = new QueryClient(); + vi.mocked(backend.readItemDetail).mockImplementation((itemId) => + Promise.resolve({ status: itemId === 1 ? 'completed' : 'in_progress' } as QueueItemDetailReadModel) + ); + + await client.fetchQuery(queueItemDetailOptions(backend, 1)); + await client.fetchQuery(queueItemDetailOptions(backend, 2)); + await invalidateQueueQueries(client); + + expect(client.getQueryState(queueKeys.itemDetail(1))?.isInvalidated).toBe(false); + expect(client.getQueryState(queueKeys.itemDetail(2))?.isInvalidated).toBe(true); + }); + it('does not continue or publish a read after its account epoch expires', async () => { accountLifecycle.activate('user-a'); const backend = createBackend(); diff --git a/invokeai/frontend/webv2/src/features/queue/data/queries.ts b/invokeai/frontend/webv2/src/features/queue/data/queries.ts index 46dddcbf8dd..bde291d74fa 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/queries.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/queries.ts @@ -1,10 +1,12 @@ import type { QueueBackendPort, + QueueItemDetailReadModel, QueueQueryScope, QueueReadModel, QueueStatusReadModel, } from '@features/queue/core/types'; +import { isTerminalBackendStatus } from '@features/queue/data/events'; import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/accountLifecycle'; import { queryOptions, type QueryClient } from '@tanstack/react-query'; @@ -12,6 +14,7 @@ export const QUEUE_RECENT_WINDOW = 50; export const queueKeys = { all: ['queue'] as const, + itemDetail: (itemId: number) => [...queueKeys.all, 'item-detail', itemId] as const, readModel: (scope: QueueQueryScope) => [...queueKeys.all, 'read-model', scope.originPrefix ?? 'all'] as const, status: (scope: QueueQueryScope) => [...queueKeys.all, 'status', scope.originPrefix ?? 'all'] as const, }; @@ -70,7 +73,34 @@ export const queueReadModelOptions = ( }); })(); -/** Coalesced by QueryClient; only active queue observers refetch. */ -export const invalidateQueueReadModels = async (queryClient: QueryClient): Promise => { - await queryClient.invalidateQueries({ queryKey: queueKeys.all }); +/** One item's saved execution data, read when a details view opens; an item's session changes only while it runs. */ +export const queueItemDetailOptions = (backend: QueueBackendPort, itemId: number) => + (() => { + const owner = captureAccountScope(); + + return queryOptions({ + queryFn: async ({ signal }): Promise => { + const detail = await backend.readItemDetail(itemId, AbortSignal.any([signal, owner.signal])); + + assertAccountScopeCurrent(owner); + + return detail; + }, + queryKey: queueKeys.itemDetail(itemId), + staleTime: 5_000, + }); + })(); + +/** + * Refresh queue reads after queue activity; coalesced by QueryClient, and only active observers refetch. A finished + * item's execution data no longer changes, so its open details are not read again on every queue event; a details + * view opened again reads them afresh. + */ +export const invalidateQueueQueries = async (queryClient: QueryClient): Promise => { + await queryClient.invalidateQueries({ + predicate: ({ queryKey, state }) => + queryKey[0] === queueKeys.all[0] && + (queryKey[1] !== 'item-detail' || + !isTerminalBackendStatus((state.data as QueueItemDetailReadModel | undefined)?.status ?? 'pending')), + }); }; diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts index c630b3d0be7..e534aedf8a2 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts @@ -12,6 +12,7 @@ import { cancelQueueItems, cancelScopedQueueItems, clearFailedQueueItems, + clearFinishedQueueItems, clearScopedQueue, getQueueItemsByIds, } from './serverApi'; @@ -117,7 +118,7 @@ describe('scoped clears', () => { if (url.includes('/item_ids')) { return Promise.resolve({ item_ids: scopeIds, total_count: scopeIds.length }); } - if (url.endsWith('/items_by_ids')) { + if (url.endsWith('/item_summaries_by_ids')) { const { item_ids: itemIds } = JSON.parse(String(init?.body)) as { item_ids: number[] }; return Promise.resolve(itemIds.map((itemId) => ({ item_id: itemId, status: 'failed' }))); } @@ -167,3 +168,78 @@ describe('getQueueItemsByIds', () => { expect(transport.apiFetchJson).not.toHaveBeenCalled(); }); }); + +describe('clearFinishedQueueItems', () => { + beforeEach(() => { + accountLifecycle.activate('user-a'); + transport.apiFetchJson.mockReset(); + }); + + it('prunes the whole queue in one server sweep when no scope is given', async () => { + transport.apiFetchJson.mockResolvedValue({ deleted: 3 }); + + await clearFinishedQueueItems(); + + expect(requestedUrls()).toEqual(['/api/v1/queue/default/prune']); + expect(transport.apiFetchJson.mock.calls[0]?.[1]).toMatchObject({ method: 'PUT' }); + }); + + it('deletes only the terminal items of a scope and leaves active work alone', async () => { + transport.apiFetchJson.mockImplementation((url: string) => { + if (url.includes('item_ids')) { + return Promise.resolve({ item_ids: [5, 4, 3, 2, 1] }); + } + if (url.endsWith('item_summaries_by_ids')) { + return Promise.resolve([ + { item_id: 7, parent_item_id: 5, root_item_id: 5, status: 'completed' }, + { item_id: 6, parent_item_id: 3, root_item_id: 3, status: 'completed' }, + { item_id: 5, status: 'waiting' }, + { item_id: 4, status: 'pending' }, + { item_id: 3, status: 'completed' }, + { item_id: 2, status: 'canceled' }, + { item_id: 1, status: 'failed' }, + ]); + } + return Promise.resolve({}); + }); + + await clearFinishedQueueItems({ originPrefix: 'webv2:p:a:' }); + + const deletions = transport.apiFetchJson.mock.calls + .filter((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE') + .map((call) => String(call[0])); + + expect(deletions.sort()).toEqual([ + '/api/v1/queue/default/i/1', + '/api/v1/queue/default/i/2', + '/api/v1/queue/default/i/3', + ]); + expect(requestedUrls()).not.toContain('/api/v1/queue/default/prune'); + // Choosing what to delete needs status and lineage alone, never the items' sessions. + expect(requestedUrls()).not.toContain('/api/v1/queue/default/items_by_ids'); + }); + + it('clears a failed workflow-call chain through its root alone', async () => { + transport.apiFetchJson.mockImplementation((url: string) => { + if (url.includes('item_ids')) { + return Promise.resolve({ item_ids: [3, 2, 1] }); + } + if (url.endsWith('item_summaries_by_ids')) { + return Promise.resolve([ + { item_id: 3, parent_item_id: 1, status: 'failed' }, + { item_id: 2, status: 'completed' }, + { item_id: 1, status: 'failed' }, + ]); + } + return Promise.resolve({}); + }); + + await clearFailedQueueItems({ originPrefix: 'webv2:p:a:' }); + + expect( + transport.apiFetchJson.mock.calls + .filter((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE') + .map((call) => String(call[0])) + ).toEqual(['/api/v1/queue/default/i/1']); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts index a9b8dd9080a..cbbc611fce1 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts @@ -1,11 +1,15 @@ import type { QueueProgressPreviewPayload, QueueQueryScope } from '@features/queue/core/types'; import type { + CancelByBatchIdsResultDTO, + InvocationCacheStatusDTO, QueueAndProcessorStatusDTO, + QueueItemDeliveryResultDTO, QueueItemIdsResultDTO, QueueServerItemDTO, + RetryItemsResultDTO, } from '@features/queue/data/serverTypes'; -import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/accountLifecycle'; +import { assertAccountScopeCurrent, captureAccountScope, type AccountScope } from '@platform/state/accountLifecycle'; import { apiFetchJson } from '@platform/transport/http'; const QUEUE_ID = 'default'; @@ -97,20 +101,52 @@ export const getQueueItemIds = ( { signal } ); -/** Hydrates items in id order, one bounded request per page of ids. */ -export const getQueueItemsByIds = async (itemIds: number[], signal?: AbortSignal): Promise => { - const items: QueueServerItemDTO[] = []; +/** Reads `action`'s answer for `itemIds` in id order, one bounded request per page of ids. */ +const readQueueItemIdPages = async ( + action: 'item_summaries_by_ids' | 'items_by_ids', + itemIds: number[], + signal?: AbortSignal +): Promise => { + const results: T[] = []; for (let start = 0; start < itemIds.length; start += QUEUE_ITEM_IDS_PER_REQUEST) { - const page = await apiFetchJson(buildQueueUrl('items_by_ids'), { + const page = await apiFetchJson(buildQueueUrl(action), { body: JSON.stringify({ item_ids: itemIds.slice(start, start + QUEUE_ITEM_IDS_PER_REQUEST) }), method: 'POST', signal, }); - items.push(...page); + results.push(...page); } - return items; + return results; +}; + +/** Hydrates items in id order. */ +export const getQueueItemsByIds = (itemIds: number[], signal?: AbortSignal): Promise => + readQueueItemIdPages('items_by_ids', itemIds, signal); + +type QueueItemSummaryDTO = Pick; + +/** The roots in `scope` whose status `include` accepts; deleting a root removes its whole workflow-call chain. */ +const readScopedRootIds = async ( + scope: QueueQueryScope, + include: (status: QueueServerItemDTO['status']) => boolean, + owner: AccountScope +): Promise => { + const idsResult = await getQueueItemIds('desc', scope, owner.signal); + + assertAccountScopeCurrent(owner); + // Just status and lineage: choosing roots needs no sessions. + const summaries = await readQueueItemIdPages( + 'item_summaries_by_ids', + idsResult.item_ids, + owner.signal + ); + + assertAccountScopeCurrent(owner); + return summaries + .filter((item) => include(item.status) && (item.parent_item_id ?? null) === null) + .map((item) => item.item_id); }; export const clearQueue = (signal?: AbortSignal): Promise => @@ -130,17 +166,36 @@ export const cancelQueueItems = async (itemIds: number[], signal?: AbortSignal): await runConcurrent(itemIds, (itemId) => cancelQueueItem(itemId, signal)); }; +/** + * Deletes failed roots with their workflow-call chains. A child that failed under a root that is still active, or was + * then canceled, stays with its root, so Clear Failed never deletes a running tree. + */ export const clearFailedQueueItems = async (scope: QueueQueryScope = {}): Promise => { const owner = captureAccountScope(); - const idsResult = await getQueueItemIds('desc', scope, owner.signal); + await deleteQueueItems(await readScopedRootIds(scope, (status) => status === 'failed', owner), owner.signal); assertAccountScopeCurrent(owner); - const items = await getQueueItemsByIds(idsResult.item_ids, owner.signal); +}; - assertAccountScopeCurrent(owner); - const failedItemIds = items.filter((item) => item.status === 'failed').map((item) => item.item_id); +const isTerminalStatus = (status: QueueServerItemDTO['status']): boolean => + status === 'completed' || status === 'canceled' || status === 'failed'; + +/** + * Removes finished history and keeps pending, waiting and running items. Without a scope the server prunes in one + * sweep; a scoped prune deletes the scope's terminal root items individually, as the clear commands do. Deleting a + * root removes its whole workflow-call chain, so children are never addressed on their own: a second delete would + * miss, and a finished child must not take a still-waiting parent with it. + */ +export const clearFinishedQueueItems = async (scope: QueueQueryScope = {}): Promise => { + const owner = captureAccountScope(); + + if (!scope.originPrefix) { + await pruneQueue(owner.signal); + assertAccountScopeCurrent(owner); + return; + } - await deleteQueueItems(failedItemIds, owner.signal); + await deleteQueueItems(await readScopedRootIds(scope, isTerminalStatus, owner), owner.signal); assertAccountScopeCurrent(owner); }; @@ -197,18 +252,61 @@ export const cancelScopedQueueItems = async ( assertAccountScopeCurrent(owner); }; -export const cancelByBatchIds = (batchIds: string[]): Promise => - apiFetchJson(buildQueueUrl('cancel_by_batch_ids'), { +export const cancelByBatchIds = async (batchIds: string[]): Promise => { + const owner = captureAccountScope(); + const result = await apiFetchJson(buildQueueUrl('cancel_by_batch_ids'), { body: JSON.stringify({ batch_ids: batchIds }), method: 'PUT', + signal: owner.signal, }); -export const retryItemsById = (itemIds: number[]): Promise => - apiFetchJson(buildQueueUrl('retry_items_by_id'), { + assertAccountScopeCurrent(owner); + + return result; +}; + +export const retryItemsById = async (itemIds: number[]): Promise => { + const owner = captureAccountScope(); + const result = await apiFetchJson(buildQueueUrl('retry_items_by_id'), { body: JSON.stringify(itemIds), method: 'PUT', + signal: owner.signal, }); + assertAccountScopeCurrent(owner); + + return result; +}; + +export const claimQueueItemDelivery = async ( + itemId: number, + action: 'claim' | 'confirm' | 'release', + claimant: string +): Promise => { + const owner = captureAccountScope(); + const result = await apiFetchJson(buildQueueUrl(`i/${itemId}/delivery`), { + body: JSON.stringify({ action, claimant }), + method: 'POST', + signal: owner.signal, + }); + + assertAccountScopeCurrent(owner); + + return result; +}; + +const INVOCATION_CACHE_URL = '/api/v1/app/invocation_cache'; + +/** Admin-only: the invocation (node output) cache, distinct from the model cache. */ +export const getInvocationCacheStatus = (signal?: AbortSignal): Promise => + apiFetchJson(`${INVOCATION_CACHE_URL}/status`, { signal }); + +export const clearInvocationCache = (signal?: AbortSignal): Promise => + apiFetchJson(INVOCATION_CACHE_URL, { method: 'DELETE', signal }); + +export const setInvocationCacheEnabled = (enabled: boolean, signal?: AbortSignal): Promise => + apiFetchJson(`${INVOCATION_CACHE_URL}/${enabled ? 'enable' : 'disable'}`, { method: 'PUT', signal }); + export const pauseQueueProcessor = (): Promise => apiFetchJson(buildQueueUrl('processor/pause'), { method: 'PUT' }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts index 48d53fa70ce..a0c145fcf4a 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts @@ -20,8 +20,12 @@ export interface QueueServerItemDTO { field_values?: QueueNodeFieldValueDTO[] | null; item_id: number; origin?: string | null; + parent_item_id?: number | null; + priority?: number | null; retried_from_item_id?: number | null; + root_item_id?: number | null; session?: { + graph?: Record; prepared_source_mapping?: Record; results?: Record; }; @@ -32,6 +36,31 @@ export interface QueueServerItemDTO { user_display_name?: string | null; user_email?: string | null; user_id?: string; + workflow?: Record | null; +} + +export interface RetryItemsResultDTO { + queue_id: string; + retried_item_ids: number[]; + /** Each retried root paired with the item that re-runs it; absent from servers that predate the pairing. */ + retried_items?: { item_id: number; source_item_id: number }[]; +} + +export interface QueueItemDeliveryResultDTO { + retry_after_ms?: number | null; + status: 'claimed' | 'delivered' | 'held' | 'released'; +} + +export interface CancelByBatchIdsResultDTO { + canceled: number; +} + +export interface InvocationCacheStatusDTO { + enabled: boolean; + hits: number; + max_size: number; + misses: number; + size: number; } export interface QueueImageDTO { diff --git a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts index 6026bb6a39c..05497748a8b 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts @@ -107,6 +107,7 @@ const getSubmittedBody = () => { expect(init?.body).toEqual(expect.any(String)); return JSON.parse(init?.body as string) as { + prepend: boolean; batch: { batch_id?: string; data: { field_name: string; items: unknown[]; node_path: string }[][]; @@ -163,6 +164,17 @@ describe('enqueueGenerate', () => { ]); }); + it('appends by default and prepends only when the request asks for the front of the queue', async () => { + const { enqueueGenerate } = await import('./submissionApi'); + + await enqueueGenerate(createRequest()); + expect(getSubmittedBody()).toMatchObject({ prepend: false }); + + mocks.apiFetchJson.mockClear(); + await enqueueGenerate(createRequest({ prepend: true })); + expect(getSubmittedBody()).toMatchObject({ prepend: true }); + }); + it('includes project-aware webv2 origin metadata', async () => { const { enqueueGenerate } = await import('./submissionApi'); @@ -328,6 +340,17 @@ describe('enqueueWorkflow', () => { }); }); + it('prepends a workflow batch only when the request asks for the front of the queue', async () => { + const { enqueueWorkflow } = await import('./submissionApi'); + + await enqueueWorkflow(createWorkflowRequest()); + expect(getSubmittedBody()).toMatchObject({ prepend: false }); + + mocks.apiFetchJson.mockClear(); + await enqueueWorkflow(createWorkflowRequest({ prepend: true })); + expect(getSubmittedBody()).toMatchObject({ prepend: true }); + }); + it('submits workflow runs for the requested batch count', async () => { const { enqueueWorkflow } = await import('./submissionApi'); diff --git a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts index 22a081e8a4e..4b22a8d4721 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts @@ -103,7 +103,7 @@ export const enqueueGenerate = async (request: QueueEnqueueGenerateRequest): Pro origin: buildQueueItemOrigin(request.sourceQueueItemId, request.projectId), runs: plan.runs, }, - prepend: false, + prepend: request.prepend === true, }), method: 'POST', }); @@ -129,7 +129,7 @@ export const enqueueWorkflow = async (request: QueueEnqueueWorkflowRequest): Pro runs: plan.runs, ...(request.workflow ? { workflow: request.workflow } : {}), }, - prepend: false, + prepend: request.prepend === true, }), method: 'POST', }); diff --git a/invokeai/frontend/webv2/src/features/queue/devices.ts b/invokeai/frontend/webv2/src/features/queue/devices.ts index bea9005b333..f19eba01ad9 100644 --- a/invokeai/frontend/webv2/src/features/queue/devices.ts +++ b/invokeai/frontend/webv2/src/features/queue/devices.ts @@ -10,8 +10,10 @@ export { type GenerationDevicesSetting, type GenerationDevicesSnapshot, getGenerationDevicesSnapshot, + type MaxQueueHistorySetting, refreshGenerationDevices, updateGenerationDevices, + updateMaxQueueHistory, useGenerationDevices, } from './data/generationDevicesStore'; export { useDeviceLabel } from './ui/useDeviceLabel'; diff --git a/invokeai/frontend/webv2/src/features/queue/index.ts b/invokeai/frontend/webv2/src/features/queue/index.ts index 77dd39b0d64..586ee6ca307 100644 --- a/invokeai/frontend/webv2/src/features/queue/index.ts +++ b/invokeai/frontend/webv2/src/features/queue/index.ts @@ -40,6 +40,7 @@ export { } from './core/historySummary'; export { BACKEND_SUBMITTABLE_SOURCE_IDS, + getQueueSourceResultSelection, isBackendSubmittableSourceId, shouldSubmitPendingQueueItem, } from './core/submissionRules'; diff --git a/invokeai/frontend/webv2/src/features/queue/publicApi.ts b/invokeai/frontend/webv2/src/features/queue/publicApi.ts index cac51c48f01..ab5587cb25c 100644 --- a/invokeai/frontend/webv2/src/features/queue/publicApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/publicApi.ts @@ -10,7 +10,7 @@ import type { import type { QueueModelLoadPort, QueueNodeExecutionPort } from './runtime/coordinator'; import { queueBackend } from './data/httpRealtimeQueueBackend'; -import { queueReadModelOptions } from './data/queries'; +import { queueItemDetailOptions, queueReadModelOptions } from './data/queries'; import { createQueueRealtimeRuntime } from './data/realtimeRuntime'; import { createQueueRuntime } from './runtime'; import { createQueueReceiptAcknowledgements, type QueueReceiptStorePort } from './runtime/receiptAcknowledgements'; @@ -23,12 +23,14 @@ export const createProductionQueueReceiptAcknowledgements = (store: QueueReceipt }); export const queueCommands: QueueFeatureCommands = { + cancelBatch: queueBackend.cancelBatch, cancelCurrentItem: queueBackend.cancelCurrentItem, cancelItem: async (itemId) => { await queueBackend.cancelItem(itemId); }, cancelScopedItems: queueBackend.cancelScopedItems, clearFailedItems: queueBackend.clearFailedItems, + clearFinishedItems: queueBackend.clearFinishedItems, clearItems: queueBackend.clearItems, pauseProcessor: async () => { await queueBackend.pauseProcessor(); @@ -36,8 +38,11 @@ export const queueCommands: QueueFeatureCommands = { resumeProcessor: async () => { await queueBackend.resumeProcessor(); }, + retryItems: queueBackend.retryItems, }; +export const getQueueItemDetailOptions = (itemId: number) => queueItemDetailOptions(queueBackend, itemId); + export const getQueueReadModelOptions = (scope: QueueQueryScope, onRead?: (model: QueueReadModel) => void) => queueReadModelOptions(queueBackend, scope, onRead); @@ -63,6 +68,7 @@ export const createProductionQueueRuntime = ({ locks, modelLoads, nodeExecution, + viewerUserId, }: { destinations: QueueResultDestinationPort; ensureProjectPersisted?(projectId: string): Promise<'ready' | 'refused' | 'retry'>; @@ -72,6 +78,7 @@ export const createProductionQueueRuntime = ({ locks?: QueueRunLockPort; modelLoads: QueueModelLoadPort; nodeExecution: QueueNodeExecutionPort; + viewerUserId: string | null; }): QueueRuntime => createQueueRuntime({ backend: queueBackend, @@ -83,4 +90,5 @@ export const createProductionQueueRuntime = ({ locks, modelLoads, nodeExecution, + viewerUserId, }); diff --git a/invokeai/frontend/webv2/src/features/queue/react.ts b/invokeai/frontend/webv2/src/features/queue/react.ts index 98f6468ed4c..63fe91fd361 100644 --- a/invokeai/frontend/webv2/src/features/queue/react.ts +++ b/invokeai/frontend/webv2/src/features/queue/react.ts @@ -17,4 +17,5 @@ export { } from './data/progressImageStore'; export { type QueueItemProgressSink, useQueueItemProgress } from './data/progressStore'; export { getQueueItemAccess } from './ui/queueOwnership'; -export { useIsProcessorPaused } from './ui/queueDataStore'; +export { type QueueItemDetailState, useIsProcessorPaused, useQueueItemDetail } from './ui/queueDataStore'; +export type { QueueItemDetailReadModel } from './core/types'; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index 9f9e7a86260..c4bb98c7c5d 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -6,10 +6,12 @@ import type { QueueResultVideo, } from '@features/queue/core/types'; import type { BackendConnectionStatus } from '@platform/transport/types'; +import type { WorkbenchQueueItem } from '@workbench/queueHistoryContracts'; import { activeProgressTargetStore } from '@features/queue/data/activeProgressTargetStore'; import { buildQueueItemOrigin } from '@features/queue/data/events'; import { ApiError } from '@platform/transport/http'; +import { createInitialWorkbenchState, type WorkbenchAction, workbenchReducer } from '@workbench/workbenchState.testing'; import { describe, expect, it, vi } from 'vitest'; import { @@ -54,7 +56,11 @@ const createTestBackend = (overrides: Partial = {}): QueueBack cancelItem: vi.fn(), cancelQueueItems: vi.fn(), cancelQueueItemsByBatchIds: vi.fn(), + cancelBatch: vi.fn(), + clearFinishedItems: vi.fn(), + readItemDetail: vi.fn(), cancelScopedItems: vi.fn(), + claimItemDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), clearFailedItems: vi.fn(), clearItems: vi.fn(), emit: vi.fn(), @@ -88,11 +94,13 @@ const createTestCommands = (overrides: Partial = {}): Queu setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), routeResults: vi.fn(), setConnectionStatus: vi.fn(), setStatus: vi.fn(), + stageRetriedCanvasResults: vi.fn(), ...overrides, }); @@ -103,6 +111,17 @@ const createTestJournal = (overrides: Partial = {}): QueueR ...overrides, }); +/** Run locks that no other tab holds, around the given acquire. */ +const createTestLocks = ( + acquire: QueueRunLockPort['acquire'], + overrides: Partial = {} +): QueueRunLockPort => ({ + acquire, + isHeld: () => Promise.resolve(false), + waitForRunRelease: () => Promise.resolve(), + ...overrides, +}); + const runtimeServices = { destinations: { addImagesToGalleryBoard: vi.fn(), addVideosToGalleryBoard: vi.fn() }, ensureTemplatesLoaded: vi.fn(), @@ -129,6 +148,34 @@ describe('queue runtime', () => { }); }); + it('carries an Invoke Front snapshot to the backend request and leaves ordinary runs appending', () => { + const ordinary = createQueueItemBackendSubmission({ id: 'project-1' }, createPendingQueueItem()) as { + request: object; + }; + + expect(ordinary.request).not.toHaveProperty('prepend'); + + const front = createPendingQueueItem(); + front.snapshot.prepend = true; + + expect(createQueueItemBackendSubmission({ id: 'project-1' }, front)).toMatchObject({ + kind: 'generate', + request: { prepend: true }, + }); + + const workflow = createPendingQueueItem(); + workflow.snapshot = { + ...workflow.snapshot, + backendSubmission: { batchCount: 1, graph: { edges: [], id: 'workflow-graph', nodes: {} }, kind: 'workflow' }, + prepend: true, + }; + + expect(createQueueItemBackendSubmission({ id: 'project-1' }, workflow)).toMatchObject({ + kind: 'workflow', + request: { prepend: true }, + }); + }); + it('replays items queued before seed modes with the step their random toggle implied', () => { const asLegacy = (shouldRandomizeSeed: boolean) => { const queueItem = createPendingQueueItem(); @@ -301,6 +348,9 @@ describe('queue runtime', () => { cancelItem: vi.fn(), cancelQueueItems: vi.fn(), cancelQueueItemsByBatchIds: vi.fn(), + cancelBatch: vi.fn(), + clearFinishedItems: vi.fn(), + readItemDetail: vi.fn(), cancelScopedItems: vi.fn(), clearFailedItems: vi.fn(), clearItems: vi.fn(), @@ -325,6 +375,7 @@ describe('queue runtime', () => { readStatus: vi.fn(), resumeProcessor: vi.fn(), retryItems: vi.fn(), + claimItemDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), }; const commands: QueueHistoryCommands = { markBackendCancelled: vi.fn(), @@ -339,11 +390,13 @@ describe('queue runtime', () => { setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), routeResults: vi.fn(), setConnectionStatus: vi.fn(), setStatus: vi.fn(), + stageRetriedCanvasResults: vi.fn(), }; const runtime = createQueueRuntime({ backend, @@ -813,14 +866,14 @@ describe('queue runtime', () => { const listeners = new Set<() => void>(); let finishAcquire: (result: { kind: 'acquired'; release(): Promise }) => void = () => undefined; const release = vi.fn().mockResolvedValue(undefined); - const locks: QueueRunLockPort = { - acquire: vi.fn( + const locks = createTestLocks( + vi.fn( () => new Promise((resolve) => { finishAcquire = resolve; }) - ), - }; + ) + ); const recordedItems: QueueItem[] = []; const journal = createTestJournal({ listForProject: vi.fn().mockResolvedValue({ @@ -878,8 +931,8 @@ describe('queue runtime', () => { let finishAcquire: (result: QueueRunLock) => void = () => undefined; const release = vi.fn().mockResolvedValue(undefined); let acquisitionCount = 0; - const locks: QueueRunLockPort = { - acquire: vi.fn(() => { + const locks = createTestLocks( + vi.fn(() => { acquisitionCount += 1; if (acquisitionCount > 1) { return Promise.resolve({ kind: 'acquired', release }); @@ -887,8 +940,8 @@ describe('queue runtime', () => { return new Promise((resolve) => { finishAcquire = resolve; }); - }), - }; + }) + ); const journal = createTestJournal({ listForProject: vi.fn().mockResolvedValue({ entries: [{ item: queueItem, projectId: 'project-1', queueItemId: queueItem.id }], @@ -1018,7 +1071,7 @@ describe('queue runtime', () => { }, }, journal: createTestJournal({ record }), - locks: { acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }) }, + locks: createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })), }); runtime.start(); @@ -1111,7 +1164,7 @@ describe('queue runtime', () => { }, }, journal, - locks: { acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }) }, + locks: createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })), }); runtime.start(); @@ -1171,7 +1224,7 @@ describe('queue runtime', () => { }, }, journal: createTestJournal({ settle }), - locks: { acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }) }, + locks: createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })), }); runtime.start(); @@ -1626,7 +1679,7 @@ describe('queue runtime', () => { }, }, journal, - locks: { acquire }, + locks: createTestLocks(acquire), }); runtime.start(); @@ -1675,7 +1728,7 @@ describe('queue runtime', () => { subscribe: vi.fn(() => vi.fn()), }, journal: createTestJournal({ record }), - locks: { acquire }, + locks: createTestLocks(acquire), }); runtime.start(); @@ -1891,7 +1944,7 @@ describe('queue runtime', () => { }), }); const acquire = vi.fn().mockResolvedValue({ kind: 'contended' }); - const locks: QueueRunLockPort = { acquire }; + const locks = createTestLocks(acquire); const backend = createTestBackend(); const runtime = createQueueRuntime({ ...runtimeServices, @@ -1959,7 +2012,7 @@ describe('queue runtime', () => { kind: 'available', }), }), - locks: { acquire }, + locks: createTestLocks(acquire), }); runtime.start(); @@ -1978,9 +2031,7 @@ describe('queue runtime', () => { const notify = () => listeners.forEach((listener) => listener()); const journal = createTestJournal(); const release = vi.fn().mockResolvedValue(undefined); - const locks: QueueRunLockPort = { - acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }), - }; + const locks = createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })); const commands = createTestCommands({ markBackendSubmitted: ({ backendBatchId, backendItemIds }) => { queueItem.backendBatchId = backendBatchId; @@ -2180,7 +2231,7 @@ describe('queue runtime', () => { }, }, journal, - locks: { acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }) }, + locks: createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })), }); runtime.start(); @@ -2228,6 +2279,9 @@ describe('queue runtime video board routing', () => { cancelItem: vi.fn(), cancelQueueItems: vi.fn(), cancelQueueItemsByBatchIds: vi.fn(), + cancelBatch: vi.fn(), + clearFinishedItems: vi.fn(), + readItemDetail: vi.fn(), cancelScopedItems: vi.fn(), clearFailedItems: vi.fn(), clearItems: vi.fn(), @@ -2278,6 +2332,7 @@ describe('queue runtime video board routing', () => { }), resumeProcessor: vi.fn(), retryItems: vi.fn(), + claimItemDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), }; const commands: QueueHistoryCommands = { markBackendCancelled: vi.fn(), @@ -2292,11 +2347,13 @@ describe('queue runtime video board routing', () => { setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), routeResults: vi.fn(), setConnectionStatus: vi.fn(), setStatus: vi.fn(), + stageRetriedCanvasResults: vi.fn(), }; const destinations = { addImagesToGalleryBoard: vi.fn(), @@ -2735,3 +2792,1036 @@ describe('queue runtime video board routing', () => { runtime.dispose(); }); }); + +describe('queue runtime retries', () => { + const createRetryHarness = ( + queueItem: QueueItem, + backendOverrides: Partial = {}, + services: { journal?: QueueRunJournalPort; locks?: QueueRunLockPort; viewerUserId?: string | null } = {} + ) => { + const project = { id: 'project-1', queue: { items: [queueItem] } }; + const projects = [project]; + const listeners = new Set<() => void>(); + const notify = () => listeners.forEach((listener) => listener()); + const closeProject = () => { + projects.splice(0); + notify(); + }; + const handlers = new Map void>>(); + const backend = createTestBackend({ + on: vi.fn((event: string, handler: (payload: never) => void) => { + const eventHandlers = handlers.get(event) ?? new Set(); + eventHandlers.add(handler); + handlers.set(event, eventHandlers); + return () => eventHandlers.delete(handler); + }), + ...backendOverrides, + }); + const commands = createTestCommands({ + routeResults: vi.fn(() => { + queueItem.status = 'completed'; + notify(); + }), + }); + const runtime = createQueueRuntime({ + ...runtimeServices, + ...services, + backend, + history: { + commands, + getSnapshot: () => ({ connectionStatus: 'connected', isHydrated: true, projects }), + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }); + const emit = (event: string, payload: unknown) => + handlers.get(event)?.forEach((handler) => handler(payload as never)); + + return { backend, closeProject, commands, emit, notify, project, runtime }; + }; + + const retriedEvent = (pairs: { item_id: number; source_item_id: number }[]) => ({ + queue_id: 'default', + retried_item_ids: pairs.map((pair) => pair.source_item_id), + retried_item_ids_by_user: {}, + retried_items: pairs, + timestamp: 0, + user_ids: [], + }); + + const completedEvent = (itemId: number, origin: string, destination: string) => ({ + batch_id: 'batch', + completed_at: null, + created_at: '2026-07-17T00:00:00.000Z', + destination, + error_message: null, + error_traceback: null, + error_type: null, + item_id: itemId, + origin, + queue_id: 'default', + session_id: `session-${itemId}`, + started_at: null, + status: 'completed', + status_sequence: null, + timestamp: 0, + updated_at: '2026-07-17T00:00:00.000Z', + user_id: 'user', + }); + + it('reopens the run that owns a retried item and routes its rerun to the run destination', async () => { + const queueItem: QueueItem = { + ...createPendingQueueItem(), + backendBatchId: 'batch', + backendItemIds: [41], + error: 'Out of memory', + status: 'failed', + }; + const rerunImage = { imageName: 'rerun.png' } as QueueResultImage; + const getResultImages = vi.fn().mockResolvedValue([rerunImage]); + const { commands, emit, notify, project, runtime } = createRetryHarness(queueItem, { + getItem: vi.fn().mockResolvedValue({ + batchId: 'batch', + id: 99, + origin: buildQueueItemOrigin(queueItem.id, 'project-1'), + status: 'completed', + }), + getResultImages, + }); + vi.mocked(commands.reopenForRetry).mockImplementation(({ retriedItems }) => { + queueItem.backendItemIds = retriedItems.map(({ itemId }) => itemId); + queueItem.status = 'running'; + delete queueItem.error; + notify(); + }); + + runtime.start(); + emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + + await vi.waitFor(() => { + expect(commands.routeResults).toHaveBeenCalledWith( + expect.objectContaining({ images: [rerunImage], projectId: project.id, queueItemId: queueItem.id }) + ); + }); + expect(commands.reopenForRetry).toHaveBeenCalledWith({ + projectId: project.id, + queueItemId: queueItem.id, + retriedItems: [{ itemId: 99, sourceItemId: 41 }], + }); + expect(getResultImages).toHaveBeenCalledWith(99, queueItem.id, queueItem.snapshot.submittedAt, { + resultNodeIds: ['canvas_output'], + }); + expect(commands.stageRetriedCanvasResults).not.toHaveBeenCalled(); + + await runtime.dispose(); + }); + + it('stages reruns through the workbench at the run bbox, including a later retry of an item the first left', async () => { + let state = createInitialWorkbenchState(); + const projectId = state.activeProjectId; + const { canvas } = state.projects.find((project) => project.id === projectId)!; + const submittedBbox = { ...canvas.document.bbox }; + const queueItem: WorkbenchQueueItem = { + ...createPendingQueueItem(), + backendItemIds: [41, 42], + snapshot: { + ...createPendingQueueItem().snapshot, + canvas: { + document: { bbox: submittedBbox, height: canvas.document.height, width: canvas.document.width }, + documentRevision: canvas.documentRevision, + }, + }, + status: 'failed', + }; + state = { + ...state, + projects: state.projects.map((project) => + project.id === projectId ? { ...project, queue: { items: [queueItem] } } : project + ), + }; + const listeners = new Set<() => void>(); + const dispatch = (action: WorkbenchAction): void => { + state = workbenchReducer(state, action); + listeners.forEach((listener) => listener()); + }; + dispatch({ + mutation: { bbox: { ...submittedBbox, x: submittedBbox.x + 64 }, type: 'setCanvasBbox' }, + projectId, + type: 'applyCanvasProjectMutation', + }); + const handlers = new Map void>>(); + const backend = createTestBackend({ + getItem: vi.fn((id: number) => + Promise.resolve({ + batchId: 'batch', + id, + origin: buildQueueItemOrigin(queueItem.id, projectId), + status: 'completed' as const, + }) + ), + getResultImages: vi.fn((id: number) => + Promise.resolve([ + { + height: 64, + imageName: `rerun-${id}.png`, + imageUrl: `/rerun-${id}.png`, + queuedAt: queueItem.snapshot.submittedAt, + sourceQueueItemId: queueItem.id, + thumbnailUrl: `/rerun-${id}.png`, + width: 64, + }, + ]) + ), + on: vi.fn((event: string, handler: (payload: never) => void) => { + const eventHandlers = handlers.get(event) ?? new Set(); + eventHandlers.add(handler); + handlers.set(event, eventHandlers); + return () => eventHandlers.delete(handler); + }), + }); + const commands = createTestCommands({ + markBackendCancelled: (payload) => dispatch({ ...payload, type: 'markQueueItemBackendCancelled' }), + markBackendSubmitted: (payload) => dispatch({ ...payload, type: 'markQueueItemBackendSubmitted' }), + reopenForRetry: (payload) => dispatch({ ...payload, type: 'reopenQueueItemForRetry' }), + routePartialResults: (payload) => dispatch({ ...payload, type: 'routeQueueItemPartialResults' }), + routeResults: (payload) => dispatch({ ...payload, type: 'routeQueueItemResults' }), + setStatus: (payload) => dispatch({ ...payload, type: 'setQueueItemStatus' }), + stageRetriedCanvasResults: vi.fn(), + }); + const runtime = createQueueRuntime({ + ...runtimeServices, + backend, + history: { + commands, + getSnapshot: () => ({ connectionStatus: 'connected', isHydrated: true, projects: state.projects }), + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }); + const emitRetried = (itemId: number, sourceItemId: number) => + handlers + .get('queue_items_retried') + ?.forEach((handler) => handler(retriedEvent([{ item_id: itemId, source_item_id: sourceItemId }]) as never)); + const getRun = () => state.projects.find((project) => project.id === projectId)!.queue.items[0]!; + const getStaged = () => + state.projects.find((project) => project.id === projectId)!.canvas.stagingArea.pendingImages; + + runtime.start(); + emitRetried(99, 41); + await vi.waitFor(() => expect(getRun().status).toBe('completed')); + emitRetried(100, 42); + await vi.waitFor(() => expect(getStaged().map((image) => image.imageName)).toContain('rerun-100.png')); + + expect(getStaged()).toEqual([ + expect.objectContaining({ + imageName: 'rerun-99.png', + placement: expect.objectContaining({ x: submittedBbox.x }), + }), + expect.objectContaining({ + imageName: 'rerun-100.png', + placement: expect.objectContaining({ x: submittedBbox.x }), + }), + ]); + expect(commands.stageRetriedCanvasResults).not.toHaveBeenCalled(); + + await runtime.dispose(); + }); + + it("stages what another tab's Workflow rerun produced, and nothing for a rerun that failed", async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }; + const image = (imageName: string, createdAt: string, isIntermediate = false) => + ({ createdAt, imageName, isIntermediate }) as QueueResultImage; + const produced = image('produced.png', '2026-07-17T00:00:05.000Z'); + const getResultImages = vi.fn((_id: number, _queueItemId: string, _queuedAt: string, options?: object) => + Promise.resolve( + options + ? [] + : [ + produced, + image('echoed-input.png', '2026-07-16T00:00:00.000Z'), + image('preview.png', '2026-07-17T00:00:04.000Z', true), + ] + ) + ); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }); + const otherTabOrigin = buildQueueItemOrigin('run-in-another-tab', project.id); + + runtime.start(); + emit( + 'queue_items_retried', + retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 100, source_item_id: 42 }, + ]) + ); + emit('queue_item_status_changed', { ...completedEvent(100, otherTabOrigin, 'canvas'), status: 'failed' }); + emit('queue_item_status_changed', completedEvent(99, otherTabOrigin, 'canvas')); + + await vi.waitFor(() => { + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith({ + backendItemId: 99, + images: [produced], + projectId: project.id, + queueItemId: 'run-in-another-tab', + }); + }); + expect(getResultImages).toHaveBeenNthCalledWith(1, 99, 'run-in-another-tab', '2026-07-17T00:00:00.000Z', { + resultNodeIds: ['canvas_output', 'upscale_output'], + }); + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledTimes(1); + + await runtime.dispose(); + }); + + it("adds a Gallery rerun its run could not adopt to that run's board", async () => { + // Still running from an earlier retry, so this rerun of its other failed item cannot reopen it. + const queueItem: QueueItem = { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41, 42], + snapshot: { ...createPendingQueueItem().snapshot, destination: 'gallery', galleryBoardId: 'board-1' }, + status: 'running', + }; + const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }); + // The destination port is shared by every test in this file. + vi.mocked(runtimeServices.destinations.addImagesToGalleryBoard).mockClear(); + + runtime.start(); + emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin(queueItem.id, project.id), 'gallery')); + + await vi.waitFor(() => expect(commands.refreshBackendData).toHaveBeenCalled()); + expect(commands.reopenForRetry).toHaveBeenCalledWith(expect.objectContaining({ queueItemId: queueItem.id })); + expect(runtimeServices.destinations.addImagesToGalleryBoard).toHaveBeenCalledWith('board-1', ['rerun.png']); + + await runtime.dispose(); + }); + + it("adds a video rerun its active run could not adopt to that run's board", async () => { + // A second failed item of a video batch, retried while the first item's rerun still runs. + const queueItem: QueueItem = { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41, 42], + snapshot: { + ...createPendingQueueItem().snapshot, + destination: 'gallery', + galleryBoardId: 'board-1', + resultNodeIds: ['video_output'], + sourceId: 'video', + }, + status: 'running', + }; + const getResultVideoNames = vi.fn().mockResolvedValue(['rerun.mp4']); + const getResultVideos = vi.fn().mockResolvedValue([{ videoName: 'rerun.mp4' } as QueueResultVideo]); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { + getResultVideoNames, + getResultVideos, + }); + // The destination port is shared by every test in this file. + vi.mocked(runtimeServices.destinations.addVideosToGalleryBoard).mockClear(); + + runtime.start(); + emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin(queueItem.id, project.id), 'gallery')); + + await vi.waitFor(() => expect(commands.refreshBackendData).toHaveBeenCalled()); + expect(getResultVideoNames).toHaveBeenCalledWith(100, { resultNodeIds: ['video_output'] }); + expect(runtimeServices.destinations.addVideosToGalleryBoard).toHaveBeenCalledWith('board-1', ['rerun.mp4']); + + await runtime.dispose(); + }); + + it("waits for the tab tracking a Canvas rerun's run, and stages the rerun if that tab lets the run go without it", async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }; + const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); + let letRunGo: (() => void) | undefined; + // Another tab tracks the run behind rerun 99; nobody tracks the one behind rerun 100 any more. + const locks = createTestLocks(vi.fn(), { + isHeld: (_projectId, queueItemId) => Promise.resolve(queueItemId === 'tracked-elsewhere'), + waitForRunRelease: () => + new Promise((resolve) => { + letRunGo = resolve; + }), + }); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }, { locks }); + const staged = () => + vi.mocked(commands.stageRetriedCanvasResults).mock.calls.map(([payload]) => payload.backendItemId); + + runtime.start(); + emit( + 'queue_items_retried', + retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 100, source_item_id: 42 }, + ]) + ); + emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin('tracked-elsewhere', project.id), 'canvas') + ); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin('run-gone', project.id), 'canvas')); + + await vi.waitFor(() => expect(staged()).toEqual([100])); + await settleEvents(); + expect(staged()).toEqual([100]); + expect(getResultImages).toHaveBeenCalledTimes(1); + letRunGo?.(); + + await vi.waitFor(() => expect(staged()).toEqual([100, 99])); + expect(locks.acquire).not.toHaveBeenCalled(); + + await runtime.dispose(); + }); + + /** + * The server's delivery claims, which every tab and browser of the account shares. A claim lasts `ttlMs` here, so a + * claimant turned away asks again within a test. + */ + const createDeliveryServer = (ttlMs = 200) => { + const claims = new Map(); + const claimItemDelivery: QueueBackendPort['claimItemDelivery'] = vi.fn((itemId, action, claimant) => { + const claim = claims.get(itemId); + if (action === 'confirm') { + claims.set(itemId, { claimant, claimedAt: Date.now(), isDelivered: true }); + return Promise.resolve({ kind: 'delivered' } as const); + } + if (action === 'release') { + if (claim !== undefined && claim.claimant === claimant && !claim.isDelivered) { + claims.delete(itemId); + } + return Promise.resolve({ kind: 'released' } as const); + } + if (claim?.isDelivered) { + return Promise.resolve({ kind: 'delivered' } as const); + } + const age = Date.now() - (claim?.claimedAt ?? 0); + if (claim !== undefined && claim.claimant !== claimant && age < ttlMs) { + return Promise.resolve({ kind: 'held', retryInMs: ttlMs - age } as const); + } + claims.set(itemId, { claimant, claimedAt: Date.now(), isDelivered: false }); + return Promise.resolve({ kind: 'claimed' } as const); + }); + return { claimItemDelivery, claims }; + }; + const rerunImages = (itemId: number) => Promise.resolve([{ imageName: `rerun-${itemId}.png` } as QueueResultImage]); + const openCanvasTab = ( + server: ReturnType, + getResultImages: QueueBackendPort['getResultImages'] = vi.fn(rerunImages), + locks: QueueRunLockPort = createTestLocks(vi.fn()) + ) => + createRetryHarness( + { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }, + { claimItemDelivery: server.claimItemDelivery, getResultImages }, + { locks } + ); + /** A tab whose failed Canvas run reopens around the reruns its retry reports, and reads them as completed. */ + const openAdoptingTab = ( + server: ReturnType, + run: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' } + ) => { + const tab = createRetryHarness(run, { + claimItemDelivery: server.claimItemDelivery, + getItem: vi.fn((id: number) => + Promise.resolve({ + batchId: 'batch', + id, + origin: buildQueueItemOrigin(run.id, 'project-1'), + status: 'completed' as const, + }) + ), + getResultImages: vi.fn(rerunImages), + }); + vi.mocked(tab.commands.reopenForRetry).mockImplementation(({ retriedItems }) => { + run.retriedBackendItemIds = run.backendItemIds; + run.backendItemIds = retriedItems.map(({ itemId }) => itemId); + run.status = 'running'; + tab.notify(); + }); + return { ...tab, run }; + }; + /** The rerun images a tab routed or staged into its Canvas. */ + const stagedImageNames = (tab: ReturnType) => + [ + ...vi.mocked(tab.commands.stageRetriedCanvasResults).mock.calls.flatMap(([payload]) => payload.images), + ...vi.mocked(tab.commands.routePartialResults).mock.calls.flatMap(([payload]) => payload.images), + ...vi.mocked(tab.commands.routeResults).mock.calls.flatMap(([payload]) => payload.images), + ].map((image) => image.imageName); + const stagedItemIds = (tab: ReturnType) => + vi.mocked(tab.commands.stageRetriedCanvasResults).mock.calls.map(([payload]) => payload.backendItemId); + const emitReruns = (tab: ReturnType, itemIds: number[]) => + tab.emit( + 'queue_items_retried', + retriedEvent(itemIds.map((itemId) => ({ item_id: itemId, source_item_id: itemId + 1_000 }))) + ); + const settleEvents = () => + new Promise((resolve) => { + setTimeout(resolve); + }); + + it('stages a Canvas rerun once across tabs and browsers, however late another sees it and after the first closed', async () => { + const server = createDeliveryServer(); + const first = openCanvasTab(server); + const second = openCanvasTab(server); + const completed = (itemId: number) => + completedEvent(itemId, buildQueueItemOrigin('run-gone', first.project.id), 'canvas'); + + for (const tab of [first, second]) { + tab.runtime.start(); + emitReruns(tab, [99, 100, 101]); + } + first.emit('queue_item_status_changed', completed(99)); + first.emit('queue_item_status_changed', completed(100)); + await vi.waitFor(() => expect(stagedItemIds(first).sort((left, right) => left - right)).toEqual([99, 100])); + await first.runtime.dispose(); + + for (const itemId of [99, 100, 101]) { + second.emit('queue_item_status_changed', completed(itemId)); + } + await vi.waitFor(() => expect(stagedItemIds(second)).toEqual([101])); + await settleEvents(); + expect(stagedItemIds(second)).toEqual([101]); + + await second.runtime.dispose(); + }); + + it('stages a Canvas rerun once when tabs handle its completion at the same moment', async () => { + const server = createDeliveryServer(); + const tabs = [0, 1, 2].map(() => openCanvasTab(server)); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', tabs[0]!.project.id), 'canvas'); + + for (const tab of tabs) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + for (const tab of tabs) { + tab.emit('queue_item_status_changed', completed); + } + + await vi.waitFor(() => expect(tabs.flatMap(stagedItemIds)).toEqual([99])); + // The winner claims, checks its claim before staging and confirms; the others ask again until it is confirmed. + await vi.waitFor(() => expect(server.claimItemDelivery).toHaveBeenCalledTimes(7)); + expect(tabs.flatMap(stagedItemIds)).toEqual([99]); + + await Promise.all(tabs.map((tab) => tab.runtime.dispose())); + }); + + it('stages a Canvas rerun after the tab that claimed it closed before staging, once the claim expires', async () => { + const server = createDeliveryServer(); + // This tab closes while it reads the results, so it neither stages nor gives the claim back. + const closing = openCanvasTab( + server, + vi.fn( + () => + new Promise(() => { + // Never settles: the tab is gone. + }) + ) + ); + const open = openCanvasTab(server); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', closing.project.id), 'canvas'); + + for (const tab of [closing, open]) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + closing.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(server.claims.has(99)).toBe(true)); + open.emit('queue_item_status_changed', completed); + await settleEvents(); + expect(stagedItemIds(open)).toEqual([]); + + await vi.waitFor(() => expect(stagedItemIds(open)).toEqual([99])); + + await open.runtime.dispose(); + }); + + it('leaves a Canvas rerun to a tab that sees it later when this one could not read its results', async () => { + // Claims outlast the test, so only giving the claim back lets the later tab stage it. + const server = createDeliveryServer(60_000); + const failing = openCanvasTab(server, vi.fn().mockRejectedValue(new Error('Server restarting'))); + const later = openCanvasTab(server); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', failing.project.id), 'canvas'); + + for (const tab of [failing, later]) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + failing.emit('queue_item_status_changed', completed); + await vi.waitFor(() => + expect(failing.commands.recordError).toHaveBeenCalledWith(expect.objectContaining({ area: 'queue-results' })) + ); + later.emit('queue_item_status_changed', completed); + + await vi.waitFor(() => expect(stagedItemIds(later)).toEqual([99])); + expect(stagedItemIds(failing)).toEqual([]); + + await failing.runtime.dispose(); + await later.runtime.dispose(); + }); + + it('stages a Canvas rerun when the server keeps no delivery claims', async () => { + const tab = openCanvasTab({ + claimItemDelivery: vi.fn().mockRejectedValue(new ApiError('Not Found', 404)), + claims: new Map(), + }); + + tab.runtime.start(); + emitReruns(tab, [99]); + tab.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin('run-gone', tab.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(stagedItemIds(tab)).toEqual([99])); + + await tab.runtime.dispose(); + }); + + it('asks again after a failed claim instead of staging a rerun another tab staged meanwhile', async () => { + const server = createDeliveryServer(); + server.claims.set(99, { claimant: 'tab:other', claimedAt: Date.now(), isDelivered: true }); + const claimItemDelivery = vi.mocked(server.claimItemDelivery); + claimItemDelivery.mockRejectedValueOnce(new Error('Failed to fetch')); + const tab = openCanvasTab(server); + + tab.runtime.start(); + emitReruns(tab, [99]); + tab.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin('run-gone', tab.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(claimItemDelivery).toHaveBeenCalledTimes(2), { timeout: 6_000 }); + await settleEvents(); + expect(stagedItemIds(tab)).toEqual([]); + + await tab.runtime.dispose(); + }, 10_000); + + it('stages a Canvas rerun that finishes while this tab is still freeing the run lock it gave up', async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [88], status: 'running' }; + let held = false; + let freeRunLock: (() => void) | undefined; + const locks = createTestLocks( + vi.fn(() => { + held = true; + return Promise.resolve({ + kind: 'acquired', + release: () => + new Promise((resolve) => { + freeRunLock = () => { + held = false; + resolve(); + }; + }), + } as const); + }), + { isHeld: () => Promise.resolve(held) } + ); + const { commands, emit, notify, project, runtime } = createRetryHarness( + queueItem, + { getResultImages: vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]) }, + { locks } + ); + + runtime.start(); + await vi.waitFor(() => expect(held).toBe(true)); + emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); + // Removing the run gives up its lock; the browser frees it only later. + project.queue.items = []; + notify(); + await vi.waitFor(() => expect(freeRunLock).toBeDefined()); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin(queueItem.id, project.id), 'canvas')); + + await vi.waitFor(() => + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith( + expect.objectContaining({ backendItemId: 100, queueItemId: queueItem.id }) + ) + ); + expect(held).toBe(true); + + freeRunLock?.(); + await runtime.dispose(); + }); + + it('leaves a Canvas rerun to another tab when its project closed here while the results loaded', async () => { + const server = createDeliveryServer(); + let finishRead!: (images: QueueResultImage[]) => void; + const closing = openCanvasTab( + server, + vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ) + ); + const open = openCanvasTab(server); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', closing.project.id), 'canvas'); + + for (const tab of [closing, open]) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + closing.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(finishRead).toBeDefined()); + closing.closeProject(); + finishRead([{ imageName: 'rerun-99.png' } as QueueResultImage]); + await settleEvents(); + open.emit('queue_item_status_changed', completed); + + await vi.waitFor(() => expect(stagedItemIds(open)).toEqual([99])); + expect(stagedItemIds(closing)).toEqual([]); + + await closing.runtime.dispose(); + await open.runtime.dispose(); + }); + + it('stages a rerun at its run in one tab while another tab leaves it, whichever handles it first', async () => { + const server = createDeliveryServer(); + const adopting = openAdoptingTab(server); + const other = openCanvasTab(server); + + for (const tab of [adopting, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + await vi.waitFor(() => expect(stagedImageNames(adopting)).toContain('rerun-99.png')); + await vi.waitFor(() => expect(server.claims.get(99)?.isDelivered).toBe(true)); + other.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin(adopting.run.id, other.project.id), 'canvas') + ); + await vi.waitFor(() => + expect(server.claimItemDelivery).toHaveBeenCalledWith(99, 'claim', expect.stringMatching(/^tab:/)) + ); + await settleEvents(); + + expect(stagedItemIds(other)).toEqual([]); + + await adopting.runtime.dispose(); + await other.runtime.dispose(); + }); + + it('leaves a rerun to the run reopened around it in another browser, even when this one sees it finish first', async () => { + // Long enough that only the run confirming lets this browser stop asking. + const server = createDeliveryServer(60_000); + const adopting = openAdoptingTab(server); + vi.mocked(adopting.backend.getItem).mockResolvedValue({ + batchId: 'batch', + id: 99, + origin: buildQueueItemOrigin(adopting.run.id, 'project-1'), + status: 'in_progress', + }); + const other = openCanvasTab(server); + const completed = completedEvent(99, buildQueueItemOrigin(adopting.run.id, other.project.id), 'canvas'); + + for (const tab of [adopting, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + // The run claims its rerun as soon as its tab holds it, long before the rerun finishes. + await vi.waitFor(() => expect(server.claims.get(99)?.claimant).toMatch(/^run:/)); + other.emit('queue_item_status_changed', completed); + await vi.waitFor(() => + expect(server.claimItemDelivery).toHaveBeenCalledWith(99, 'claim', expect.stringMatching(/^tab:/)) + ); + adopting.emit('queue_item_status_changed', completed); + + await vi.waitFor(() => expect(stagedImageNames(adopting)).toContain('rerun-99.png')); + expect(server.claims.get(99)?.isDelivered).toBe(true); + await vi.waitFor( + () => + expect( + vi.mocked(server.claimItemDelivery).mock.calls.filter(([, , claimant]) => claimant.startsWith('tab:')) + ).toHaveLength(2), + { timeout: 6_000 } + ); + await settleEvents(); + expect(stagedItemIds(other)).toEqual([]); + + await adopting.runtime.dispose(); + await other.runtime.dispose(); + }, 10_000); + + it('keeps the claim of a rerun its run is still waiting on', async () => { + vi.useFakeTimers({ toFake: ['setInterval'] }); + try { + const server = createDeliveryServer(60_000); + const adopting = openAdoptingTab(server); + vi.mocked(adopting.backend.getItem).mockResolvedValue({ + batchId: 'batch', + id: 99, + origin: buildQueueItemOrigin(adopting.run.id, 'project-1'), + status: 'pending', + }); + + adopting.runtime.start(); + adopting.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + await vi.waitFor(() => expect(server.claims.get(99)?.claimant).toMatch(/^run:/)); + await settleEvents(); + // The rerun waits in the queue for longer than an unrenewed claim would last. + server.claims.set(99, { ...server.claims.get(99)!, claimedAt: Date.now() - 50_000 }); + vi.advanceTimersByTime(40_000); + + await vi.waitFor(() => expect(Date.now() - server.claims.get(99)!.claimedAt).toBeLessThan(10_000)); + + await adopting.runtime.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('gives back the claim of a rerun whose project closed before its run staged it', async () => { + // Claims outlast the test, so only giving the claim back lets the other tab stage it. + const server = createDeliveryServer(60_000); + const adopting = openAdoptingTab(server); + vi.mocked(adopting.backend.getItem).mockResolvedValue({ + batchId: 'batch', + id: 99, + origin: buildQueueItemOrigin(adopting.run.id, 'project-1'), + status: 'in_progress', + }); + const other = openCanvasTab(server); + + for (const tab of [adopting, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + await vi.waitFor(() => expect(server.claims.get(99)?.claimant).toMatch(/^run:/)); + adopting.closeProject(); + await vi.waitFor(() => expect(server.claims.has(99)).toBe(false)); + other.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin(adopting.run.id, other.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([99])); + + await adopting.runtime.dispose(); + await other.runtime.dispose(); + }); + + it('does not stage a rerun whose claim lapsed while its results loaded and another tab staged meanwhile', async () => { + const server = createDeliveryServer(); + let finishRead: ((images: QueueResultImage[]) => void) | undefined; + const slow = openCanvasTab( + server, + vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ) + ); + const other = openCanvasTab(server); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', slow.project.id), 'canvas'); + + for (const tab of [slow, other]) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + slow.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(finishRead).toBeDefined()); + other.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([99])); + finishRead?.([{ imageName: 'rerun-99.png' } as QueueResultImage]); + await settleEvents(); + await settleEvents(); + + expect(stagedItemIds(slow)).toEqual([]); + + await slow.runtime.dispose(); + await other.runtime.dispose(); + }); + + it('leaves out of a reopened run a rerun another tab or browser staged first', async () => { + const server = createDeliveryServer(); + const staging = openCanvasTab(server); + // Reopened around rerun 99 in a tab that closed, or by a retry this tab learned of late, and restored here. + const restored = openAdoptingTab(server, { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41], + status: 'running', + }); + + staging.runtime.start(); + emitReruns(staging, [99]); + staging.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin(restored.run.id, staging.project.id), 'canvas') + ); + await vi.waitFor(() => expect(stagedItemIds(staging)).toEqual([99])); + restored.runtime.start(); + + await vi.waitFor(() => expect(restored.commands.routeResults).toHaveBeenCalled()); + expect(stagedImageNames(restored)).toEqual([]); + + await staging.runtime.dispose(); + await restored.runtime.dispose(); + }); + + it('stages a rerun in the run restored after its tab closed while the run held the claim', async () => { + const server = createDeliveryServer(60_000); + const run: QueueItem = { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41], + status: 'running', + }; + // The run's tab claimed the rerun and closed before confirming; the reload restores the same run. + server.claims.set(99, { + claimant: `run:${JSON.stringify(['project-1', run.id])}`, + claimedAt: Date.now(), + isDelivered: false, + }); + const restored = openAdoptingTab(server, run); + + restored.runtime.start(); + + await vi.waitFor(() => expect(stagedImageNames(restored)).toContain('rerun-99.png')); + await vi.waitFor(() => expect(server.claims.get(99)?.isDelivered).toBe(true)); + + await restored.runtime.dispose(); + }); + + it('gives back the claim of a rerun whose run ends before staging it, so another tab stages it', async () => { + // Claims outlast the test, so only giving the claim back lets the other tab stage it. + const server = createDeliveryServer(60_000); + const adopting = openAdoptingTab(server); + let finishRead: ((images: QueueResultImage[]) => void) | undefined; + // The run is removed while it reads. + vi.mocked(adopting.backend.getResultImages).mockReturnValue( + new Promise((resolve) => { + finishRead = resolve; + }) + ); + const other = openCanvasTab(server); + + for (const tab of [adopting, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + await vi.waitFor(() => expect(server.claims.get(99)?.claimant).toMatch(/^run:/)); + adopting.project.queue.items = []; + adopting.notify(); + other.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin(adopting.run.id, other.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([99])); + + finishRead?.([]); + await adopting.runtime.dispose(); + await other.runtime.dispose(); + }); + + it('stages a rerun its run refused to reopen around in a tab that has its project open', async () => { + const server = createDeliveryServer(); + // Still running, so the run refuses to reopen around the rerun of its other failed item. + const activeRun: QueueItem = { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41, 42], + status: 'running', + }; + const refusing = createRetryHarness(activeRun, { claimItemDelivery: server.claimItemDelivery }); + const other = openCanvasTab(server); + const retried = retriedEvent([{ item_id: 100, source_item_id: 42 }]); + + for (const tab of [refusing, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retried); + } + await vi.waitFor(() => expect(refusing.commands.reopenForRetry).toHaveBeenCalled()); + other.emit( + 'queue_item_status_changed', + completedEvent(100, buildQueueItemOrigin(activeRun.id, other.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([100])); + + await refusing.runtime.dispose(); + await other.runtime.dispose(); + }); + + it("handles a retry once when both the tab's own answer and the socket deliver it, and ignores others' retries", async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; + const { commands, emit, notify, project, runtime } = createRetryHarness(queueItem, {}, { viewerUserId: 'me' }); + vi.mocked(commands.reopenForRetry).mockImplementation(({ retriedItems }) => { + queueItem.retriedBackendItemIds = [...(queueItem.backendItemIds ?? [])]; + queueItem.backendItemIds = retriedItems.map(({ itemId }) => itemId); + queueItem.status = 'running'; + notify(); + }); + const ownAnswer = retriedEvent([{ item_id: 99, source_item_id: 41 }]); + + runtime.start(); + emit('queue_items_retried', ownAnswer); + // The socket copy an admin receives: the viewer's pair again, plus another user's. + emit('queue_items_retried', { + ...retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 200, source_item_id: 70 }, + ]), + retried_item_ids_by_user: { me: [41], other: [70] }, + user_ids: ['me', 'other'], + }); + emit( + 'queue_item_status_changed', + completedEvent(200, buildQueueItemOrigin('their-run', 'their-project'), 'gallery') + ); + + await vi.waitFor(() => expect(project.queue.items[0]?.backendItemIds).toEqual([99])); + expect(commands.reopenForRetry).toHaveBeenCalledTimes(1); + expect(commands.refreshBackendData).not.toHaveBeenCalled(); + + await runtime.dispose(); + }); + + it('delivers reruns no run adopted to the Gallery or the current Canvas, and nothing else', async () => { + // The owning run refuses to reopen (its Canvas document was replaced); the other rerun belongs to another tab. + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; + const rerunImage = { imageName: 'rerun.png' } as QueueResultImage; + const getResultImages = vi.fn().mockResolvedValue([rerunImage]); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }); + const canvasOrigin = buildQueueItemOrigin(queueItem.id, project.id); + + runtime.start(); + emit( + 'queue_items_retried', + retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 100, source_item_id: 42 }, + ]) + ); + emit('queue_item_status_changed', completedEvent(99, canvasOrigin, 'canvas')); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin('other-run', project.id), 'gallery')); + emit('queue_item_status_changed', completedEvent(101, canvasOrigin, 'canvas')); + + await vi.waitFor(() => { + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith({ + backendItemId: 99, + images: [rerunImage], + projectId: project.id, + queueItemId: queueItem.id, + }); + }); + expect(getResultImages).toHaveBeenCalledTimes(1); + expect(getResultImages).toHaveBeenCalledWith(99, queueItem.id, '2026-07-17T00:00:00.000Z', { + resultNodeIds: ['canvas_output'], + }); + expect(commands.refreshBackendData).toHaveBeenCalledTimes(1); + expect(queueItem.status).toBe('failed'); + + await runtime.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index faf82093555..0729d5b8429 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -3,6 +3,7 @@ import type { QueueBackendPort, QueueEnqueueGenerateRequest, QueueEnqueueWorkflowRequest, + QueueItemDeliveryClaim, QueueResultImage, QueueResultImageOptions, QueueResultVideo, @@ -19,7 +20,15 @@ import { MAX_QUEUE_BATCH_ITEMS, type QueueWorkflowSeed, } from '@features/queue/core/promptBatch'; -import { shouldSubmitPendingQueueItem } from '@features/queue/core/submissionRules'; +import { QUEUE_SOURCE_RESULT_NODE_IDS, shouldSubmitPendingQueueItem } from '@features/queue/core/submissionRules'; +import { + isTerminalBackendStatus, + isUtilityQueueItemOrigin, + parseQueueItemOrigin, + parseQueueItemOriginProjectId, + type QueueItemStatusChangedEvent, + type QueueItemsRetriedEvent, +} from '@features/queue/data/events'; import { progressImageStore } from '@features/queue/data/progressImageStore'; import { createQueueCoordinator, @@ -31,8 +40,10 @@ import { type ReconcileInput, type ReconcileOutcome, } from '@features/queue/runtime/coordinator'; +import { createUuid } from '@platform/browser/randomUuid'; import { mapWithConcurrency } from '@platform/core/concurrency'; import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; +import { normalizeServerTimestamp } from '@platform/time/serverTimestamp'; import { ApiError, getApiErrorMessage } from '@platform/transport/http'; export interface QueueResultDestinationPort { @@ -63,6 +74,10 @@ export type QueueRunLock = { kind: 'acquired'; release(): Promise } | { ki export interface QueueRunLockPort { acquire(projectId: string, queueItemId: string): Promise; + /** Settle once no tab holds the run, or the signal aborts. */ + waitForRunRelease(projectId: string, queueItemId: string, signal: AbortSignal): Promise; + /** Whether any tab holds the run, asked without requesting it; null when the browser cannot tell. */ + isHeld(projectId: string, queueItemId: string): Promise; } export interface QueueHistoryProject { @@ -86,6 +101,12 @@ export interface QueueHistoryCommands { }): void; recordError(payload: { area: string; message: string; namespace: 'queue'; projectId?: string }): void; refreshBackendData(): void; + /** Reopen a finished run around the backend items re-running its failed or canceled ones; refused runs stay. */ + reopenForRetry(payload: { + projectId: string; + queueItemId: string; + retriedItems: { itemId: number; sourceItemId: number }[]; + }): void; restoreFromJournal(payload: { items: unknown[]; projectId: string }): void; routePartialResults(payload: { backendItemId: number; @@ -101,6 +122,13 @@ export interface QueueHistoryCommands { videos: QueueResultVideo[]; }): void; setConnectionStatus(payload: { error?: string; status: BackendConnectionStatus }): void; + /** Stage a retried Canvas run's results at the current bbox when no open run could adopt it. */ + stageRetriedCanvasResults(payload: { + backendItemId: number; + images: QueueResultImage[]; + projectId: string; + queueItemId: string; + }): void; setStatus(payload: { error?: string; notify?: boolean; @@ -291,6 +319,7 @@ export const createQueueItemBackendSubmission = ( ...compiled, destination: queueItem.snapshot.destination, ...(isQueueSeedStep(submission.seedStep) ? {} : { legacySeedPlan: true as const }), + ...(queueItem.snapshot.prepend === true ? { prepend: true } : {}), projectId: project.id, seedStep, sourceQueueItemId: queueItem.id, @@ -324,6 +353,7 @@ export const createQueueItemBackendSubmission = ( request: { ...compiled, destination: queueItem.snapshot.destination, + ...(queueItem.snapshot.prepend === true ? { prepend: true } : {}), projectId: project.id, sourceQueueItemId: queueItem.id, }, @@ -352,6 +382,16 @@ const getRouteTarget = (history: QueueHistoryPort, localQueueItemId: string) => const getRunKey = (projectId: string, queueItemId: string): string => JSON.stringify([projectId, queueItemId]); const QUEUE_RUNTIME_CONCURRENCY = 16; +/** + * Reruns no open run adopted, remembered until they finish so their results still reach the Gallery or Canvas; one + * retry can re-run up to the backend's default `max_queue_size`. + */ +const UNADOPTED_RERUN_LIMIT = 10_000; +/** How long a claimant turned away from a rerun waits at most before asking again whether its holder confirmed. */ +const RERUN_CLAIM_POLL_MS = 3_000; +/** How long the server keeps an unconfirmed delivery claim (its contract), and how often a run renews its claims. */ +const RERUN_CLAIM_TTL_MS = 120_000; +const RERUN_CLAIM_RENEW_MS = 40_000; interface RunAttempt { generation: number; @@ -380,6 +420,7 @@ export const createQueueRuntime = ({ locks, modelLoads, nodeExecution, + viewerUserId = null, }: { backend: QueueBackendPort; destinations: QueueResultDestinationPort; @@ -390,6 +431,8 @@ export const createQueueRuntime = ({ locks?: QueueRunLockPort; modelLoads: QueueModelLoadPort; nodeExecution: QueueNodeExecutionPort; + /** The signed-in user on a multiuser server; null in single-user mode, where every item is the viewer's. */ + viewerUserId?: string | null; }): QueueRuntime => { const owner = captureAccountScope(); const commands = history.commands; @@ -414,6 +457,8 @@ export const createQueueRuntime = ({ const reportedCancelFailures = new Set(); const ownedLocks = new Map>(); const lockRequests = new Map>(); + /** Runs this tab gave up whose browser lock is not yet free; their lock still reads as held. */ + const releasingRuns = new Map>(); const preparingRunKeys = new Set(); const activeAttempts = new Map(); const ownershipEpochs = new Map(); @@ -427,6 +472,7 @@ export const createQueueRuntime = ({ const cancelRequestedRunKeys = new Set(); let reconcileRetryDelayMs = 100; let retryTimer: ReturnType | undefined; + let renewTimer: ReturnType | undefined; let hasInitialized = false; let isInitializing = false; let isReconciling = false; @@ -614,7 +660,13 @@ export const createQueueRuntime = ({ return; } ownedLocks.delete(key); - void lock.release().catch(() => undefined); + const released = lock.release().catch(() => undefined); + releasingRuns.set(key, released); + void released.finally(() => { + if (releasingRuns.get(key) === released) { + releasingRuns.delete(key); + } + }); }; const releaseRunOwnershipAfterJournal = (projectId: string, queueItemId: string): void => { @@ -722,6 +774,7 @@ export const createQueueRuntime = ({ } trackedRuns.delete(key); durableJournalSignatures.delete(key); + releaseAdoptedReruns(projectId, queueItemId); startedRunKeys.delete(key); pendingReconcileRunKeys.delete(key); reconcileRetryRunKeys.delete(key); @@ -994,19 +1047,21 @@ export const createQueueRuntime = ({ } }; - /** - * Remove input passthroughs and requested intermediates before routing; echoed source media must not move boards - * on every run. - */ - const deliverVisibleImages = async ( - queueItem: QueueItem, - allImages: QueueResultImage[] - ): Promise => { + /** Drop input passthroughs and requested intermediates; echoed source media must not move boards on every run. */ + const selectVisibleImages = (queueItem: QueueItem, allImages: QueueResultImage[]): QueueResultImage[] => { const inputMedia = collectGraphInputMediaNames(getQueueItemCompiledGraph(queueItem)); const producedImages = allImages.filter((image) => !inputMedia.imageNames.has(image.imageName)); - const images = queueItem.snapshot.filterIntermediateResults + + return queueItem.snapshot.filterIntermediateResults ? producedImages.filter((image) => !image.isIntermediate) : producedImages; + }; + + const deliverVisibleImages = async ( + queueItem: QueueItem, + allImages: QueueResultImage[] + ): Promise => { + const images = selectVisibleImages(queueItem, allImages); await addImagesToDestination(queueItem, images); @@ -1049,6 +1104,7 @@ export const createQueueRuntime = ({ return; } + confirmAdoptedReruns(projectId, queueItem, backendItemIds); commands.routeResults({ images, projectId, queueItemId: queueItem.id, videos }); if (queueItem.snapshot.destination === 'gallery') { commands.refreshBackendData(); @@ -1079,14 +1135,16 @@ export const createQueueRuntime = ({ attempt: RunAttempt ): Promise => { try { - const images = await runResultRead(() => - backend.getResultImages( - backendItemId, - queueItem.id, - queueItem.snapshot.submittedAt, - getQueueItemResultImageOptions(queueItem) - ) - ); + const images = isAdoptedRerunDecidedHere(projectId, queueItem, backendItemId) + ? await runResultRead(() => + backend.getResultImages( + backendItemId, + queueItem.id, + queueItem.snapshot.submittedAt, + getQueueItemResultImageOptions(queueItem) + ) + ) + : []; if (!isAttemptCurrent(attempt)) { return; @@ -1100,7 +1158,10 @@ export const createQueueRuntime = ({ return; } - const visibleImages = await deliverVisibleImages(queueItem, images); + // A renewal may have found the claim lost while the results loaded; another claimant stages the rerun then. + const visibleImages = isAdoptedRerunDecidedHere(projectId, queueItem, backendItemId) + ? await deliverVisibleImages(queueItem, images) + : []; // Never throws — a board-attach hiccup must not block routePartialResults below. const videos = await deliverResultVideos(projectId, queueItem, [backendItemId]); @@ -1114,6 +1175,7 @@ export const createQueueRuntime = ({ queueItem.id, visibleImages.map((image) => image.imageName) ); + confirmAdoptedReruns(projectId, queueItem, [backendItemId]); commands.routePartialResults({ backendItemId, images: visibleImages, @@ -1196,7 +1258,13 @@ export const createQueueRuntime = ({ const coordinatorBackend: QueueBackendPort = { ...backend, - getResultImages: (...args) => runResultRead(() => backend.getResultImages(...args)), + getResultImages: async (itemId, localQueueItemId, ...rest) => { + const target = getRouteTarget(history, localQueueItemId); + if (target?.queueItem && !(await isAdoptedRerunStagedHere(target.project.id, target.queueItem, itemId))) { + return []; + } + return runResultRead(() => backend.getResultImages(itemId, localQueueItemId, ...rest)); + }, }; const coordinator = createQueueCoordinator( { @@ -1248,6 +1316,398 @@ export const createQueueRuntime = ({ { backend: coordinatorBackend, modelLoads, nodeExecution } ); + /** Backend ids of reruns no run in this tab adopted. */ + const unadoptedReruns = new Set(); + const rememberUnadoptedRerun = (itemId: number): void => { + unadoptedReruns.add(itemId); + for (const oldest of unadoptedReruns) { + if (unadoptedReruns.size <= UNADOPTED_RERUN_LIMIT) { + break; + } + unadoptedReruns.delete(oldest); + } + }; + + /** Ends every wait on another tab's run, or on a rerun another claimant holds, once this runtime stops. */ + const rerunWaits = new AbortController(); + const waitForRerunRetry = (delayMs: number): Promise => + new Promise((resolve) => { + const timer = setTimeout(resolve, delayMs); + rerunWaits.signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true } + ); + }); + /** This tab, as the claimant of the reruns it stages outside any run. */ + const tabClaimant = `tab:${createUuid()}`; + /** A run, as the claimant of the reruns it was reopened around; the run restored by a reload claims as it did. */ + const getRunClaimant = (projectId: string, queueItemId: string): string => `run:${getRunKey(projectId, queueItemId)}`; + + /** + * Whether this claimant stages a retried Canvas item, as the server agrees it for every browser and tab of the + * account. While another claimant holds the item it asks again, until that one confirms or its claim expires; a + * request that fails is asked again for as long as a claim could last. A server without claims (a 404) lets every + * claimant stage it, as before claims existed. + */ + const claimRerunDelivery = async (itemId: number, claimant: string, canDeliver: () => boolean): Promise => { + for (let failures = 0; ;) { + let claim: QueueItemDeliveryClaim; + try { + claim = await backend.claimItemDelivery(itemId, 'claim', claimant); + } catch (error) { + failures += 1; + if ( + (error instanceof ApiError && error.status === 404) || + failures * RERUN_CLAIM_POLL_MS >= RERUN_CLAIM_TTL_MS + ) { + return canDeliver(); + } + claim = { kind: 'held', retryInMs: RERUN_CLAIM_POLL_MS }; + } + if (claim.kind !== 'held') { + return claim.kind === 'claimed'; + } + // Soon enough to see the holder confirm, and spread out so claimants turned away together do not ask together. + await waitForRerunRetry(Math.min(claim.retryInMs, RERUN_CLAIM_POLL_MS) * (1 + Math.random() / 2)); + if (!canDeliver()) { + return false; + } + } + }; + /** Whether this claimant still holds an item it claimed, renewing the claim; a server that cannot answer keeps it. */ + const isRerunClaimHeld = async (itemId: number, claimant: string): Promise => { + try { + return (await backend.claimItemDelivery(itemId, 'claim', claimant)).kind === 'claimed'; + } catch { + return true; + } + }; + const settleRerunClaim = (itemId: number, action: 'confirm' | 'release', claimant: string): void => { + void backend.claimItemDelivery(itemId, action, claimant).catch(() => undefined); + }; + + /** + * The Canvas reruns the runs of this tab were reopened around, each claimed by its run as soon as the tab holds the + * run and renewed while the run lasts, so a tab or browser that sees the rerun finish first leaves it to the run. + */ + interface AdoptedRerunClaim { + decision: Promise; + isClaimed: boolean; + isConfirmed: boolean; + } + const adoptedRerunClaims = new Map(); + const getAdoptedRerunKey = (projectId: string, queueItemId: string, backendItemId: number): string => + JSON.stringify([projectId, queueItemId, backendItemId]); + const isAdoptedRerun = (queueItem: QueueItem, backendItemId: number): boolean => + queueItem.snapshot.destination === 'canvas' && + Boolean(queueItem.retriedBackendItemIds?.length) && + queueItem.backendItemIds?.includes(backendItemId) === true; + const claimAdoptedRerun = (projectId: string, queueItemId: string, backendItemId: number): AdoptedRerunClaim => { + const key = getAdoptedRerunKey(projectId, queueItemId, backendItemId); + const existing = adoptedRerunClaims.get(key); + if (existing) { + return existing; + } + const claim: AdoptedRerunClaim = { + // A claim the run let go of meanwhile stops asking. + decision: claimRerunDelivery( + backendItemId, + getRunClaimant(projectId, queueItemId), + () => isActive() && adoptedRerunClaims.get(key) === claim + ).then((isClaimed) => { + claim.isClaimed = isClaimed; + return isClaimed; + }), + isClaimed: false, + isConfirmed: false, + }; + adoptedRerunClaims.set(key, claim); + return claim; + }; + /** Claim the Canvas reruns of a run this tab holds that it has yet to stage. */ + const claimAdoptedReruns = (projectId: string, queueItem: QueueItem): void => { + for (const backendItemId of queueItem.backendItemIds ?? []) { + if (isAdoptedRerun(queueItem, backendItemId) && !queueItem.completedBackendItemIds?.includes(backendItemId)) { + claimAdoptedRerun(projectId, queueItem.id, backendItemId); + } + } + }; + /** Keep the claims of runs still going from lapsing; a claim lost meanwhile leaves its rerun out of the run. */ + const renewAdoptedRerunClaims = (): void => { + for (const [key, claim] of adoptedRerunClaims) { + if (!claim.isClaimed || claim.isConfirmed) { + continue; + } + const [projectId, queueItemId, backendItemId] = JSON.parse(key) as [string, string, number]; + void backend + .claimItemDelivery(backendItemId, 'claim', getRunClaimant(projectId, queueItemId)) + .then((renewal) => { + if (renewal.kind !== 'claimed' && adoptedRerunClaims.get(key) === claim && !claim.isConfirmed) { + claim.isClaimed = false; + } + }) + .catch(() => undefined); + } + }; + /** + * Whether a run stages a rerun it was reopened around. A rerun another tab or browser staged first, as when the run's + * tab had closed, is left out of the run's results, so a run restored after that never stages it again. + */ + const isAdoptedRerunStagedHere = async ( + projectId: string, + queueItem: QueueItem, + backendItemId: number + ): Promise => { + if (!isAdoptedRerun(queueItem, backendItemId)) { + return true; + } + const claim = claimAdoptedRerun(projectId, queueItem.id, backendItemId); + await claim.decision; + return claim.isClaimed; + }; + /** + * Whether a run stages a rerun now, without waiting on a claim still undecided; the run's end stages it once decided. + * Result routing is shared by every run of the tab, so one claim must not hold the others up. + */ + const isAdoptedRerunDecidedHere = (projectId: string, queueItem: QueueItem, backendItemId: number): boolean => + !isAdoptedRerun(queueItem, backendItemId) || + adoptedRerunClaims.get(getAdoptedRerunKey(projectId, queueItem.id, backendItemId))?.isClaimed === true; + /** + * Confirm the reruns a run is about to stage. Called just before their results are routed, since routing them can + * finish the run, and a finished run gives back the claims it did not confirm. + */ + const confirmAdoptedReruns = (projectId: string, queueItem: QueueItem, backendItemIds: number[]): void => { + for (const backendItemId of backendItemIds) { + const claim = adoptedRerunClaims.get(getAdoptedRerunKey(projectId, queueItem.id, backendItemId)); + if (claim?.isClaimed && !claim.isConfirmed) { + claim.isConfirmed = true; + settleRerunClaim(backendItemId, 'confirm', getRunClaimant(projectId, queueItem.id)); + } + } + }; + /** + * A run this tab lets go of, whether it ended or its project closed, gives back the claims it did not confirm, so + * another tab stages those reruns at once; a later hold of the run claims again. + */ + const releaseAdoptedReruns = (projectId: string, queueItemId: string): void => { + for (const [key, claim] of adoptedRerunClaims) { + const [keyProjectId, keyQueueItemId, backendItemId] = JSON.parse(key) as [string, string, number]; + if (keyProjectId !== projectId || keyQueueItemId !== queueItemId) { + continue; + } + adoptedRerunClaims.delete(key); + if (!claim.isConfirmed) { + void claim.decision.then((isClaimed) => { + if (isClaimed) { + settleRerunClaim(backendItemId, 'release', getRunClaimant(projectId, queueItemId)); + } + }); + } + } + }; + + /** + * A retry, from any tab or client, re-runs failed or canceled backend items under new ids. The open run that owns + * the retried items, or once owned them before an earlier retry, reopens around the reruns, so its captured + * destination receives their results; reconciliation then tracks them like any resumed run. + */ + const handleItemsRetried = (event: QueueItemsRetriedEvent): void => { + if (!isActive() || !event.retried_items?.length) { + return; + } + + const runsByBackendItemId = new Map(); + const trackedItemIds = new Set(); + for (const project of history.getSnapshot().projects) { + for (const queueItem of project.queue.items) { + for (const backendItemId of [...(queueItem.backendItemIds ?? []), ...(queueItem.retriedBackendItemIds ?? [])]) { + runsByBackendItemId.set(backendItemId, { projectId: project.id, queueItemId: queueItem.id }); + } + for (const backendItemId of queueItem.backendItemIds ?? []) { + trackedItemIds.add(backendItemId); + } + } + } + // An admin's socket carries every user's retries; only the viewer's own can belong to runs or projects here. + const viewerSourceIds = + viewerUserId && event.user_ids.length > 0 ? new Set(event.retried_item_ids_by_user[viewerUserId] ?? []) : null; + + const retriedItemsByRun = new Map< + string, + { projectId: string; queueItemId: string; retriedItems: { itemId: number; sourceItemId: number }[] } + >(); + for (const { item_id: itemId, source_item_id: sourceItemId } of event.retried_items) { + // The retrying tab sees its pairs twice, from its own response and from the socket; the first one handles them. + if ( + (viewerSourceIds && !viewerSourceIds.has(sourceItemId)) || + trackedItemIds.has(itemId) || + unadoptedReruns.has(itemId) + ) { + continue; + } + const owner = runsByBackendItemId.get(sourceItemId); + + if (!owner) { + rememberUnadoptedRerun(itemId); + continue; + } + const key = getRunKey(owner.projectId, owner.queueItemId); + const run = retriedItemsByRun.get(key) ?? { ...owner, retriedItems: [] }; + run.retriedItems.push({ itemId, sourceItemId }); + retriedItemsByRun.set(key, run); + } + + for (const [key, run] of retriedItemsByRun) { + commands.reopenForRetry(run); + const reopened = getRouteTarget(history, run.queueItemId)?.queueItem; + let isAdopted = false; + + for (const { itemId } of run.retriedItems) { + if (reopened?.status === 'running' && reopened.backendItemIds?.includes(itemId)) { + isAdopted = true; + } else { + rememberUnadoptedRerun(itemId); + } + } + if (isAdopted) { + // A settle still waiting on the journal belongs to the finished run; it must not remove the reopened record. + pendingJournalSettles.delete(key); + pendingReconcileRunKeys.add(key); + } + } + synchronize(); + }; + + /** + * The images a rerun no run adopted produced. Its run, when this tab still holds it, says which nodes are results; + * otherwise the product sources' result nodes are tried before every image the rerun created, as for a Workflow. + */ + const readUnadoptedRerunImages = async ( + event: QueueItemStatusChangedEvent, + queueItemId: string, + run: QueueItem | undefined + ): Promise => { + const read = (options?: QueueResultImageOptions) => + runResultRead(() => backend.getResultImages(event.item_id, queueItemId, event.created_at, options)); + + if (run) { + return selectVisibleImages(run, await read(getQueueItemResultImageOptions(run))); + } + + const productResults = await read({ + resultNodeIds: [ + ...(QUEUE_SOURCE_RESULT_NODE_IDS.generate ?? []), + ...(QUEUE_SOURCE_RESULT_NODE_IDS.upscale ?? []), + ], + }); + if (productResults.length > 0) { + return productResults; + } + // Images older than the rerun are inputs its graph echoed back, not results. + const queuedAt = Date.parse(normalizeServerTimestamp(event.created_at)); + return (await read()).filter( + (image) => !image.isIntermediate && (!image.createdAt || Date.parse(image.createdAt) >= queuedAt) + ); + }; + + /** + * Whether another tab tracks the run behind a Canvas rerun no run adopted. That tab knows the run's result nodes, so + * it stages the rerun and this one waits for it; the lock is only looked at here, so that tab is never turned away. + * Tabs that do not track the run, and other browsers, are kept apart by the server's delivery claim instead. + */ + const isRunTrackedElsewhere = async (projectId: string, queueItemId: string): Promise => { + const key = getRunKey(projectId, queueItemId); + if (!locks || ownedLocks.has(key) || lockRequests.has(key) || releasingRuns.has(key)) { + return false; + } + try { + return (await locks.isHeld(projectId, queueItemId)) === true; + } catch { + return false; + } + }; + + /** + * Deliver a rerun no open run adopted the way its destination would see any untracked result: the Gallery refreshes + * (onto its run's board when this tab still holds that run), and one tab with its Canvas project open stages it. + */ + const handleUnadoptedRerunSettled = (event: QueueItemStatusChangedEvent): void => { + if (!isActive() || !isTerminalBackendStatus(event.status) || !unadoptedReruns.delete(event.item_id)) { + return; + } + if (event.status !== 'completed' || isUtilityQueueItemOrigin(event.origin)) { + return; + } + + const projectId = parseQueueItemOriginProjectId(event.origin); + const queueItemId = parseQueueItemOrigin(event.origin); + const target = queueItemId ? getRouteTarget(history, queueItemId) : undefined; + const run = target?.project.id === projectId ? target?.queueItem : undefined; + const isProjectOpen = history.getSnapshot().projects.some((project) => project.id === projectId); + const recordError = (error: unknown): void => { + if (isActive()) { + commands.recordError({ + area: 'queue-results', + message: toErrorMessage(error), + namespace: 'queue', + ...(projectId ? { projectId } : {}), + }); + } + }; + + if (event.destination === 'gallery') { + if (!run || !target || !queueItemId) { + commands.refreshBackendData(); + return; + } + // Video graphs leave their output unboarded, so videos need the same board routing as an adopted rerun's. + void Promise.all([ + readUnadoptedRerunImages(event, queueItemId, run).then((images) => addImagesToDestination(run, images)), + deliverResultVideos(target.project.id, run, [event.item_id]), + ]) + .catch(recordError) + .finally(() => { + if (isActive()) { + commands.refreshBackendData(); + } + }); + return; + } + if (event.destination !== 'canvas' || !projectId || !queueItemId || !isProjectOpen) { + return; + } + + const canStage = (): boolean => + isActive() && history.getSnapshot().projects.some((project) => project.id === projectId); + const stage = async (): Promise => { + // A tab that tracks the run knows its result nodes and stages the rerun itself; this one waits until that tab lets + // the run go, and then stages only a rerun it left. + if (await isRunTrackedElsewhere(projectId, queueItemId)) { + await locks?.waitForRunRelease(projectId, queueItemId, rerunWaits.signal); + } + if (!canStage() || !(await claimRerunDelivery(event.item_id, tabClaimant, canStage))) { + return; + } + // Confirmed only once staged, so a tab that cannot stage it, or closes first, leaves the rerun to the next. + let isStaged = false; + try { + const images = await readUnadoptedRerunImages(event, queueItemId, run); + // A read slower than the claim lasts may have let another claimant stage it meanwhile. + if (images.length > 0 && canStage() && (await isRerunClaimHeld(event.item_id, tabClaimant)) && canStage()) { + commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); + isStaged = true; + } + } finally { + settleRerunClaim(event.item_id, isStaged ? 'confirm' : 'release', tabClaimant); + } + }; + void stage().catch(recordError); + }; + const submitQueueItem = async ( project: QueueHistoryProject, queueItem: QueueItem, @@ -1424,6 +1884,7 @@ export const createQueueRuntime = ({ activeKeys.add(key); if (!locks || ownedLocks.has(key) || trackedRuns.has(key)) { void recordActiveRun(project.id, item); + claimAdoptedReruns(project.id, item); } } else if (item.status === 'cancelled' && item.cancellationPending === true) { activeKeys.add(key); @@ -1451,6 +1912,7 @@ export const createQueueRuntime = ({ settleRun(tracked.projectId, tracked.queueItemId); } else { coordinator.detachRun(tracked.queueItemId); + releaseAdoptedReruns(tracked.projectId, tracked.queueItemId); trackedRuns.delete(key); startedRunKeys.delete(key); pendingReconcileRunKeys.delete(key); @@ -1470,6 +1932,7 @@ export const createQueueRuntime = ({ pendingReconcileRunKeys.delete(key); reconcileRetryRunKeys.delete(key); coordinator.detachRun(attempt.queueItemId); + releaseAdoptedReruns(attempt.projectId, attempt.queueItemId); if (!trackedRuns.has(key)) { releaseRunOwnershipAfterJournal(attempt.projectId, attempt.queueItemId); } @@ -1934,8 +2397,11 @@ export const createQueueRuntime = ({ isStarted = true; coordinator.connect(); ensureTemplatesLoaded(); + renewTimer = setInterval(renewAdoptedRerunClaims, RERUN_CLAIM_RENEW_MS); detachers.push( history.subscribe(synchronize), + backend.on('queue_items_retried', handleItemsRetried), + backend.on('queue_item_status_changed', handleUnadoptedRerunSettled), backend.onConnectionChange((status, error) => { if (!isActive()) { return; @@ -1960,6 +2426,7 @@ export const createQueueRuntime = ({ const dispose = async (): Promise => { isDisposed = true; + rerunWaits.abort(); if (retryTimer !== undefined) { clearTimeout(retryTimer); retryTimer = undefined; @@ -1991,6 +2458,15 @@ export const createQueueRuntime = ({ ) ); } + if (renewTimer !== undefined) { + clearInterval(renewTimer); + renewTimer = undefined; + } + // A run restored by the next hold claims again as the same claimant; meanwhile another tab may stage the rerun. + for (const key of adoptedRerunClaims.keys()) { + const [projectId, queueItemId] = JSON.parse(key) as [string, string]; + releaseAdoptedReruns(projectId, queueItemId); + } startedRunKeys.clear(); preparingRunKeys.clear(); pendingJournalSettles.clear(); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx new file mode 100644 index 00000000000..0417f7f24d5 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx @@ -0,0 +1,161 @@ +import type * as ServerApi from '@features/queue/data/serverApi'; +/* eslint-disable react-perf/jsx-no-jsx-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ +import type { InvocationCacheStatusDTO } from '@features/queue/data/serverTypes'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { queryClient } from '@platform/query/client'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { system } from '@theme/system'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InvocationCacheFooter } from './InvocationCacheFooter'; +import { QueueUiProvider, type QueueUiAdapter } from './QueueUiContext'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const server = vi.hoisted(() => ({ + clearInvocationCache: vi.fn<(signal?: AbortSignal) => Promise>(), + getInvocationCacheStatus: vi.fn<(signal?: AbortSignal) => Promise>(), + setInvocationCacheEnabled: vi.fn<(enabled: boolean, signal?: AbortSignal) => Promise>(), +})); +vi.mock('@features/queue/data/serverApi', async (importOriginal) => ({ + ...(await importOriginal()), + ...server, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const status = (overrides: Partial = {}): InvocationCacheStatusDTO => ({ + enabled: true, + hits: 3, + max_size: 512, + misses: 1, + size: 4, + ...overrides, +}); + +const notify = { error: vi.fn(), info: vi.fn(), success: vi.fn() }; +const adapter: QueueUiAdapter = { + ItemActions: () => null, + activeProjectId: null, + canManageInvocationCache: true, + canManageItem: () => true, + canManageProcessor: true, + canViewItemDetails: () => true, + isConnected: true, + isProjectOpen: () => true, + notify, + openQueue: () => undefined, + queueJobsScope: 'all', +}; + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + queryClient.clear(); + vi.clearAllMocks(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +const render = async () => { + await act(() => + root.render( + + + + + + + + ) + ); + // The status read retries once before it reports a failure. + await vi.waitFor(() => expect(host.textContent).not.toContain('widgets.queue.invocationCache.loading'), { + timeout: 5_000, + }); + await act(() => host.querySelector('button[aria-expanded]')!.click()); +}; + +const button = (label: string) => + [...host.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(label))!; + +describe('InvocationCacheFooter', () => { + it('disables the cache controls and says why when the server keeps no cache', async () => { + server.getInvocationCacheStatus.mockResolvedValue(status({ enabled: false, max_size: 0, size: 0 })); + + await render(); + + expect(host.textContent).toContain('widgets.queue.invocationCache.off'); + expect(host.textContent).toContain('widgets.queue.invocationCache.noCapacity'); + expect(button('widgets.queue.invocationCache.clear').disabled).toBe(true); + expect(button('widgets.queue.invocationCache.enable').disabled).toBe(true); + for (const label of ['widgets.queue.invocationCache.clear', 'widgets.queue.invocationCache.enable']) { + const description = document.getElementById(button(label).getAttribute('aria-describedby') ?? ''); + expect(description?.textContent).toBe('widgets.queue.invocationCache.noCapacity'); + } + }); + + it('says the cache is unavailable, with the reason, and offers no command when its status cannot be read', async () => { + server.getInvocationCacheStatus.mockRejectedValue(new Error('Server offline')); + + await render(); + + expect(host.textContent).toContain('widgets.queue.invocationCache.unavailable'); + expect(host.textContent).toContain('Server offline'); + expect(button('widgets.queue.invocationCache.clear').disabled).toBe(true); + expect(button('widgets.queue.invocationCache.enable').disabled).toBe(true); + expect(server.clearInvocationCache).not.toHaveBeenCalled(); + }); + + it('disables a running cache, refreshes its status and reports a failed clear', async () => { + server.getInvocationCacheStatus.mockResolvedValue(status()); + server.setInvocationCacheEnabled.mockResolvedValue(null); + server.clearInvocationCache.mockRejectedValue(new Error('Server offline')); + + await render(); + expect(host.textContent).toContain('widgets.queue.invocationCache.sizeOfMax'); + + server.getInvocationCacheStatus.mockResolvedValue(status({ enabled: false })); + await act(() => button('widgets.queue.invocationCache.disable').click()); + + await vi.waitFor(() => expect(button('widgets.queue.invocationCache.enable')).toBeDefined()); + expect(server.setInvocationCacheEnabled).toHaveBeenCalledWith(false, expect.any(AbortSignal)); + expect(notify.success).toHaveBeenCalledWith('widgets.queue.invocationCache.disableDone'); + + await act(() => button('widgets.queue.invocationCache.clear').click()); + + await vi.waitFor(() => + expect(notify.error).toHaveBeenCalledWith('widgets.queue.invocationCache.clearFailed', 'Server offline') + ); + }); + + it('reports nothing for a command whose account signed out while it ran', async () => { + server.getInvocationCacheStatus.mockResolvedValue(status()); + server.clearInvocationCache.mockImplementation( + (signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason)); + }) + ); + + await render(); + await act(() => button('widgets.queue.invocationCache.clear').click()); + await act(() => { + accountLifecycle.invalidate(); + }); + + await vi.waitFor(() => expect(button('widgets.queue.invocationCache.clear').disabled).toBe(false)); + expect(notify.error).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx new file mode 100644 index 00000000000..689d8e60af5 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx @@ -0,0 +1,153 @@ +import { Collapsible, HStack, Icon, Stack, Text } from '@chakra-ui/react'; +import { invocationCacheStatusOptions, runInvocationCacheCommand } from '@features/queue/data/invocationCacheQueries'; +import { queryClient } from '@platform/query/client'; +import { getApiErrorMessage } from '@platform/transport/http'; +import { Button } from '@platform/ui/Button'; +import { useQuery } from '@tanstack/react-query'; +import { ChevronUpIcon, LayersIcon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'; +import { useCallback, useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useQueueUi } from './QueueUiContext'; + +const INDICATOR_OPEN = { transform: 'rotate(180deg)' } as const; +const TRIGGER_HOVER = { color: 'fg' } as const; + +const CacheStat = ({ label, value }: { label: string; value: number }) => ( + + {label}{' '} + + {value} + + +); + +/** + * The invocation cache keeps node outputs so an unchanged node is not re-executed; it is not the model cache. The + * server exposes it to administrators only, so the footer renders for them alone. + */ +export const InvocationCacheFooter = () => { + const { t } = useTranslation(); + const { notify } = useQueueUi(); + const [expanded, setExpanded] = useState(false); + const [busy, setBusy] = useState<'clear' | 'disable' | 'enable' | null>(null); + const query = useQuery(invocationCacheStatusOptions()); + const status = query.data ?? null; + + const handleOpenChange = useCallback((event: { open: boolean }) => setExpanded(event.open), []); + const run = useCallback( + async (command: 'clear' | 'disable' | 'enable') => { + setBusy(command); + + try { + if (await runInvocationCacheCommand(queryClient, command)) { + notify.success(t(`widgets.queue.invocationCache.${command}Done`)); + } + } catch (error) { + notify.error( + t(`widgets.queue.invocationCache.${command}Failed`), + getApiErrorMessage(error, t('widgets.queue.invocationCache.couldNotChange')) + ); + } finally { + setBusy(null); + } + }, + [notify, t] + ); + const onClear = useCallback(() => run('clear'), [run]); + const onToggleEnabled = useCallback(() => run(status?.enabled ? 'disable' : 'enable'), [run, status?.enabled]); + // With no capacity the server ignores every command, so none is offered; only its configuration can turn it on. + const hasNoCapacity = status?.maxSize === 0; + const noCapacityId = useId(); + const commandDescription = hasNoCapacity ? noCapacityId : undefined; + const canCommand = status !== null && !hasNoCapacity && busy === null; + + const summary = query.isError + ? t('widgets.queue.invocationCache.unavailable') + : status + ? hasNoCapacity + ? t('widgets.queue.invocationCache.off') + : status.enabled + ? t('widgets.queue.invocationCache.sizeOfMax', { max: status.maxSize, size: status.size }) + : t('widgets.queue.invocationCache.disabled') + : t('widgets.queue.invocationCache.loading'); + + return ( + + + + {query.isError ? ( + + {getApiErrorMessage(query.error, t('widgets.queue.invocationCache.unavailable'))} + + ) : ( + + + + + + )} + {hasNoCapacity ? ( + + {t('widgets.queue.invocationCache.noCapacity')} + + ) : null} + + + + + + + + + + + {t('widgets.queue.invocationCache.label')} + + + {summary} + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueDataRuntime.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueDataRuntime.tsx index b83c6883403..ea74729c184 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueDataRuntime.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueDataRuntime.tsx @@ -1,5 +1,6 @@ import { refreshGenerationDevices } from '@features/queue/data/generationDevicesStore'; import { itemProgressStore } from '@features/queue/data/itemProgressStore'; +import { invalidateQueueQueries } from '@features/queue/data/queries'; import { createProductionQueueRealtimeRuntime } from '@features/queue/publicApi'; import { queryClient } from '@platform/query/client'; import { useMountEffect } from '@platform/react/useMountEffect'; @@ -10,7 +11,7 @@ import { clearQueueConfirmation, useQueueConfirmation } from './queueConfirmatio export const attachQueueDataRuntime = (): (() => void) => { const runtime = createProductionQueueRealtimeRuntime({ - invalidate: () => queryClient.invalidateQueries({ queryKey: ['queue'] }), + invalidate: () => invalidateQueueQueries(queryClient), progress: itemProgressStore, refreshModelCache: refreshModelCacheStats, }); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueFooter.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueFooter.tsx new file mode 100644 index 00000000000..76a364aa3ec --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueFooter.tsx @@ -0,0 +1,15 @@ +import { InvocationCacheFooter } from './InvocationCacheFooter'; +import { ModelCacheFooter } from './ModelCacheFooter'; +import { useQueueUi } from './QueueUiContext'; + +/** Both server caches sit under the queue; the invocation cache is only readable by administrators. */ +export const QueueFooter = () => { + const { canManageInvocationCache } = useQueueUi(); + + return ( + <> + {canManageInvocationCache ? : null} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx index ea8e53ab6ad..b5a5cb43d7e 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx @@ -1,7 +1,7 @@ import { Icon, Menu } from '@chakra-ui/react'; import { queueCommands } from '@features/queue/publicApi'; import { getApiErrorMessage } from '@platform/transport/http'; -import { Trash2Icon, TrashIcon } from 'lucide-react'; +import { BrushCleaningIcon, Trash2Icon, TrashIcon } from 'lucide-react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -32,6 +32,21 @@ export const QueueHeaderMenu = () => { } }, [notify, scope, t]); + const clearFinished = useCallback(async () => { + try { + await queueCommands.clearFinishedItems(scope); + notify.success(t('widgets.queue.finishedItemsCleared')); + } catch (error) { + notify.error( + t('widgets.queue.failedToClearItems'), + getApiErrorMessage(error, t('widgets.queue.couldNotClearFinishedItems')) + ); + } finally { + // A scoped clear deletes item by item, so a failure part-way has still changed the list. + await refreshQueue(); + } + }, [notify, scope, t]); + const clearAll = useCallback(async () => { try { await queueCommands.clearItems(scope); @@ -52,6 +67,14 @@ export const QueueHeaderMenu = () => { title: t('widgets.queue.clearFailedTitle'), }); }, [clearFailed, t]); + const onClearFinished = useCallback(() => { + requestQueueConfirmation({ + body: t('widgets.queue.clearFinishedConfirmationBody'), + confirmLabel: t('widgets.queue.clearFinishedItems'), + onConfirm: clearFinished, + title: t('widgets.queue.clearFinishedTitle'), + }); + }, [clearFinished, t]); const onClearAll = useCallback(() => { requestQueueConfirmation({ body: t('widgets.queue.clearQueueConfirmationBody'), @@ -73,6 +96,15 @@ export const QueueHeaderMenu = () => { {t('widgets.queue.clearFailedItems')} + + + {t('widgets.queue.clearFinishedItems')} + {t('widgets.queue.clearAllItems')} diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx new file mode 100644 index 00000000000..f7ebb61cf9f --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx @@ -0,0 +1,163 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +import type { QueueItemReadModel } from '@features/queue/core/types'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { system } from '@theme/system'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as QueueDataStore from './queueDataStore'; + +import { clearQueueConfirmation, getQueueConfirmation } from './queueConfirmationStore'; +import { QueueItemDetailActions } from './QueueItemDetailActions'; +import { QueueUiProvider, type QueueUiAdapter } from './QueueUiContext'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string, values?: { count?: number }) => `${key}${values?.count ?? ''}` }), +})); +const commands = vi.hoisted(() => ({ cancelBatch: vi.fn<(batchId: string) => Promise>() })); +vi.mock('@features/queue/publicApi', () => ({ + getQueueItemDetailOptions: (itemId: number) => ({ queryFn: () => null, queryKey: ['queue', 'item-detail', itemId] }), + getQueueReadModelOptions: () => ({ queryFn: () => null, queryKey: ['queue'] }), + queueCommands: commands, +})); +const refreshQueue = vi.hoisted(() => vi.fn(() => Promise.resolve())); +vi.mock('./queueDataStore', async (importOriginal) => ({ + ...(await importOriginal()), + refreshQueue, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const item = { batchId: 'batch-7', id: 7, status: 'completed' } as QueueItemReadModel; +const notify = { error: vi.fn(), info: vi.fn(), success: vi.fn() }; +const adapter = (canManage: boolean): QueueUiAdapter => ({ + ItemActions: () => null, + activeProjectId: null, + canManageInvocationCache: false, + canManageItem: () => canManage, + canManageProcessor: true, + canViewItemDetails: () => true, + isConnected: true, + isProjectOpen: () => true, + notify, + openQueue: () => undefined, + queueJobsScope: 'all', +}); + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.clearAllMocks(); + clearQueueConfirmation(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +const render = async (canManage = true) => { + await act(() => + root.render( + + + + + + ) + ); +}; + +const requestCancelBatch = async () => { + await act(() => host.querySelector('button')!.click()); + return getQueueConfirmation(); +}; + +describe('QueueItemDetailActions', () => { + it('cancels the batch of a finished row only once the user confirms', async () => { + commands.cancelBatch.mockResolvedValue(2); + await render(); + + expect((await requestCancelBatch())?.title).toBe('widgets.queue.cancelBatchTitle'); + clearQueueConfirmation(); + expect(commands.cancelBatch).not.toHaveBeenCalled(); + + await act(() => requestCancelBatch().then((confirmation) => confirmation?.onConfirm())); + + expect(commands.cancelBatch).toHaveBeenCalledWith('batch-7'); + expect(notify.success).toHaveBeenCalledWith( + 'widgets.queue.batchCancellationRequested', + 'widgets.queue.batchCanceledCount2' + ); + }); + + it('says there was nothing left to cancel instead of reporting zero cancellations', async () => { + commands.cancelBatch.mockResolvedValue(0); + await render(); + + await act(() => requestCancelBatch().then((confirmation) => confirmation?.onConfirm())); + + expect(notify.info).toHaveBeenCalledWith( + 'widgets.queue.batchNothingToCancel', + 'widgets.queue.batchNothingToCancelDescription' + ); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it('reports a failed cancellation, but not to an account that signed in meanwhile', async () => { + commands.cancelBatch.mockRejectedValueOnce(new Error('Server offline')); + await render(); + + await act(() => requestCancelBatch().then((confirmation) => confirmation?.onConfirm())); + expect(notify.error).toHaveBeenCalledWith('widgets.queue.failedToCancelBatch', 'Server offline'); + + let rejectCancel: (reason: Error) => void = () => undefined; + commands.cancelBatch.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectCancel = reject; + }) + ); + const confirmation = await requestCancelBatch(); + const pending = act(() => confirmation?.onConfirm()); + accountLifecycle.invalidate(); + rejectCancel(new Error('Aborted')); + await pending; + + expect(notify.error).toHaveBeenCalledTimes(1); + }); + + it('reports nothing for a cancellation that finished after the account changed', async () => { + let resolveCancel: (canceled: number) => void = () => undefined; + commands.cancelBatch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCancel = resolve; + }) + ); + await render(); + + const confirmation = await requestCancelBatch(); + const pending = act(() => confirmation?.onConfirm()); + accountLifecycle.invalidate(); + resolveCancel(3); + await pending; + + expect(refreshQueue).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.info).not.toHaveBeenCalled(); + }); + + it('offers nothing to a viewer who cannot manage the item', async () => { + await render(false); + + expect(host.querySelector('button')).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx new file mode 100644 index 00000000000..0c4758e6807 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx @@ -0,0 +1,74 @@ +import type { QueueItemReadModel } from '@features/queue/core/types'; + +import { Icon } from '@chakra-ui/react'; +import { queueCommands } from '@features/queue/publicApi'; +import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; +import { getApiErrorMessage } from '@platform/transport/http'; +import { Button } from '@platform/ui/Button'; +import { LayersIcon } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { requestQueueConfirmation } from './queueConfirmationStore'; +import { refreshQueue } from './queueDataStore'; +import { useQueueUi } from './QueueUiContext'; + +/** + * Queue-owned actions of an expanded row. Cancel batch stops every unfinished sibling of this item without touching + * other batches; it stays available on finished rows because their siblings may still be queued. + */ +export const QueueItemDetailActions = ({ item }: { item: QueueItemReadModel }) => { + const { t } = useTranslation(); + const { canManageItem, notify } = useQueueUi(); + const [busy, setBusy] = useState(false); + + const cancelBatch = useCallback(async () => { + const owner = captureAccountScope(); + setBusy(true); + + try { + const canceled = await queueCommands.cancelBatch(item.batchId); + if (!isAccountScopeCurrent(owner)) { + return; + } + await refreshQueue(); + if (canceled === 0) { + notify.info(t('widgets.queue.batchNothingToCancel'), t('widgets.queue.batchNothingToCancelDescription')); + } else { + notify.success( + t('widgets.queue.batchCancellationRequested'), + t('widgets.queue.batchCanceledCount', { count: canceled }) + ); + } + } catch (error) { + if (isAccountScopeCurrent(owner)) { + notify.error( + t('widgets.queue.failedToCancelBatch'), + getApiErrorMessage(error, t('widgets.queue.couldNotCancelBatch')) + ); + } + } finally { + setBusy(false); + } + }, [item.batchId, notify, t]); + + const onCancelBatch = useCallback(() => { + requestQueueConfirmation({ + body: t('widgets.queue.cancelBatchConfirmationBody'), + confirmLabel: t('widgets.queue.cancelBatch'), + onConfirm: cancelBatch, + title: t('widgets.queue.cancelBatchTitle'), + }); + }, [cancelBatch, t]); + + if (!canManageItem(item)) { + return null; + } + + return ( + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetails.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetails.tsx index 35e924adb7c..b5cf9fcc98e 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetails.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetails.tsx @@ -1,13 +1,14 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; import type { ReactNode } from 'react'; -import { Box, DataList, Separator, Text } from '@chakra-ui/react'; +import { Box, DataList, Flex, Separator, Text } from '@chakra-ui/react'; import { extractGenerationMeta } from '@features/queue/core/generationMeta'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; import { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDuration } from './formatDuration'; +import { QueueItemDetailActions } from './QueueItemDetailActions'; import { useQueueUi } from './QueueUiContext'; import { useDeviceLabel } from './useDeviceLabel'; @@ -71,11 +72,14 @@ export const QueueItemDetails = ({ item }: { item: QueueItemReadModel }) => { ) : null} - {/* ItemActions is a lazy chunk. Without this boundary the first expand - suspends against the widget root and blanks the whole queue panel. */} - - - + + + {/* ItemActions is a lazy chunk. Without this boundary the first expand + suspends against the widget root and blanks the whole queue panel. */} + + + + ); }; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx index e883edc7738..86be433478a 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx @@ -2,6 +2,7 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; import { ChakraProvider } from '@chakra-ui/react'; +import { buildQueueItemOrigin } from '@features/queue/data/events'; import { system } from '@theme/system'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -13,9 +14,18 @@ import { QueueItemRow } from './QueueItemRow'; import { QueueUiProvider, type QueueUiAdapter } from './QueueUiContext'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const commands = vi.hoisted(() => ({ + cancelItem: vi.fn(() => Promise.resolve()), + retryItems: vi.fn((itemIds: number[]) => Promise.resolve(itemIds)), +})); + vi.mock('@features/queue/publicApi', () => ({ + getQueueItemDetailOptions: (itemId: number) => ({ + queryFn: () => Promise.resolve(null), + queryKey: ['queue', 'item-detail', itemId], + }), getQueueReadModelOptions: () => ({ queryFn: () => Promise.resolve(null), queryKey: ['queue'] }), - queueCommands: { cancelItem: () => Promise.resolve() }, + queueCommands: commands, })); vi.mock('./queueDataStore', async (importOriginal) => ({ ...(await importOriginal()), @@ -40,10 +50,12 @@ const item = (overrides: Partial = {}): QueueItemReadModel = const adapter = (overrides: Partial = {}): QueueUiAdapter => ({ ItemActions: () => null, activeProjectId: null, + canManageInvocationCache: false, canManageItem: () => true, canManageProcessor: true, canViewItemDetails: () => true, isConnected: true, + isProjectOpen: () => true, notify: { error: () => undefined, info: () => undefined, success: () => undefined }, openQueue: () => undefined, preloadItemActions: () => undefined, @@ -136,4 +148,78 @@ describe('QueueItemRow', () => { expect(preloadItemActions).toHaveBeenCalledTimes(1); }); + + it('lets a waiting workflow-call parent be canceled from its own row', async () => { + commands.cancelItem.mockClear(); + await render(adapter(), item({ id: 11, status: 'waiting' })); + + const cancel = host.querySelector('button[aria-label="widgets.queue.cancelItem"]')!; + + expect(cancel).not.toBeNull(); + expect(host.textContent).toContain('common.status.waiting'); + await act(() => cancel.click()); + expect(commands.cancelItem).toHaveBeenCalledWith(11); + }); + + it('offers retry on a failed root item and reports the retried id', async () => { + const success = vi.fn(); + commands.retryItems.mockClear(); + await render( + adapter({ notify: { error: () => undefined, info: () => undefined, success } }), + item({ id: 12, status: 'failed' }) + ); + + expect(host.querySelector('button[aria-label="widgets.queue.cancelItem"]')).toBeNull(); + const retry = host.querySelector('button[aria-label="widgets.queue.retryItem"]')!; + + expect(retry).not.toBeNull(); + await act(async () => { + retry.click(); + await Promise.resolve(); + }); + expect(commands.retryItems).toHaveBeenCalledWith([12]); + expect(success).toHaveBeenCalledWith('widgets.queue.retryQueued', 'widgets.queue.retryQueuedDescription'); + }); + + it('retries a Canvas job of a project closed here, and says another tab with it open receives the result', async () => { + const success = vi.fn(); + const canvasJob = (id: number, projectId: string) => + item({ destination: 'canvas', id, origin: buildQueueItemOrigin('run-1', projectId), status: 'failed' }); + const retry = async (queueItem: QueueItemReadModel) => { + await act(() => root.unmount()); + root = createRoot(host); + await render( + adapter({ + isProjectOpen: (projectId) => projectId === 'open-project', + notify: { error: () => undefined, info: () => undefined, success }, + }), + queueItem + ); + await act(async () => { + host.querySelector('button[aria-label="widgets.queue.retryItem"]')!.click(); + await Promise.resolve(); + }); + }; + commands.retryItems.mockClear(); + + await retry(canvasJob(15, 'closed-project')); + expect(commands.retryItems).toHaveBeenCalledWith([15]); + expect(success).toHaveBeenLastCalledWith( + 'widgets.queue.retryQueued', + 'widgets.queue.retryQueuedProjectClosedDescription' + ); + + await retry(canvasJob(16, 'open-project')); + expect(success).toHaveBeenLastCalledWith('widgets.queue.retryQueued', 'widgets.queue.retryQueuedDescription'); + }); + + it('hides retry on a failed workflow-call child and on rows the viewer cannot manage', async () => { + await render(adapter(), item({ id: 13, parentItemId: 5, status: 'failed' })); + expect(host.querySelector('button[aria-label="widgets.queue.retryItem"]')).toBeNull(); + + await act(() => root.unmount()); + root = createRoot(host); + await render(adapter({ canManageItem: () => false }), item({ id: 14, status: 'canceled' })); + expect(host.querySelector('button[aria-label="widgets.queue.retryItem"]')).toBeNull(); + }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx index abb677a1a87..fb5474ea32c 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx @@ -3,6 +3,7 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; import { Box, HStack, Icon, Text } from '@chakra-ui/react'; import { extractGenerationMeta, getResultImageName } from '@features/queue/core/generationMeta'; +import { parseQueueItemOriginProjectId } from '@features/queue/data/events'; import { useItemProgress } from '@features/queue/data/itemProgressStore'; import { ListItem } from '@platform/ui/list/ListItem'; import { ChevronRightIcon } from 'lucide-react'; @@ -13,10 +14,12 @@ import { CancelQueueItemButton } from './CancelQueueItemButton'; import { formatCompactAge, formatDuration } from './formatDuration'; import { QueueItemDetails } from './QueueItemDetails'; import { QueueItemThumbnail } from './QueueItemThumbnail'; +import { isCancellableQueueItem, isRetryableQueueItem } from './queueOwnership'; import { QueueStatusDot } from './QueueStatusDot'; import { QueueStepProgress } from './QueueStepProgress'; import { useQueueUi } from './QueueUiContext'; import { clearPendingQueueItemReveal, type QueueItemRevealRequest } from './queueUiStore'; +import { RetryQueueItemButton } from './RetryQueueItemButton'; import { getStatusMeta } from './statusMeta'; import { useDeviceLabel } from './useDeviceLabel'; @@ -25,7 +28,7 @@ const CHEVRON_OPEN = { transform: 'rotate(90deg)' } as const; export const QueueItemRow = memo( ({ item, revealRequest }: { item: QueueItemReadModel; revealRequest?: QueueItemRevealRequest | null }) => { const { t } = useTranslation(); - const { canManageItem, canViewItemDetails, preloadItemActions } = useQueueUi(); + const { canManageItem, canViewItemDetails, isProjectOpen, preloadItemActions } = useQueueUi(); const [expanded, setExpanded] = useState(false); const toggle = useCallback(() => setExpanded((open) => !open), []); // Warm the actions chunk on intent so the first expand has nothing to wait for. @@ -36,7 +39,13 @@ export const QueueItemRow = memo( const age = formatCompactAge(item.completedAt ?? item.createdAt); const ageLabel = [duration, age].filter(Boolean).join(' · '); const isFailed = item.status === 'failed'; - const isCancellable = (item.status === 'pending' || item.status === 'in_progress') && canManageItem(item); + const canManage = canManageItem(item); + const isCancellable = isCancellableQueueItem(item) && canManage; + const isRetryable = isRetryableQueueItem(item) && canManage; + const originProjectId = parseQueueItemOriginProjectId(item.origin); + // A Canvas rerun is staged by a tab with its project open, which need not be this one (or this user's). + const isRetryForProjectClosedHere = + item.destination === 'canvas' && originProjectId !== null && !isProjectOpen(originProjectId); const canExpand = canViewItemDetails(item); const consumeReveal = useCallback( @@ -70,7 +79,13 @@ export const QueueItemRow = memo( return ( : undefined} + actions={ + isCancellable ? ( + + ) : isRetryable ? ( + + ) : undefined + } description={ diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx index 8ec8cc678f7..53a14bee09c 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx @@ -12,10 +12,14 @@ export interface QueueUiNotificationPort { export interface QueueUiAdapter { ItemActions: ComponentType<{ item: QueueItemReadModel }>; activeProjectId: string | null; + /** The invocation cache endpoints are admin-only; the footer hides for everyone else. */ + canManageInvocationCache: boolean; canManageProcessor: boolean; canManageItem(item: QueueItemReadModel): boolean; canViewItemDetails(item: QueueItemReadModel): boolean; isConnected: boolean; + /** Whether `projectId` is open in this tab; a Canvas rerun is staged only into a project open here. */ + isProjectOpen(projectId: string): boolean; notify: QueueUiNotificationPort; openQueue(): void; /** diff --git a/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx b/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx new file mode 100644 index 00000000000..812a0b582da --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx @@ -0,0 +1,74 @@ +import type { MouseEvent as ReactMouseEvent } from 'react'; + +import { Icon } from '@chakra-ui/react'; +import { queueCommands } from '@features/queue/publicApi'; +import { getApiErrorMessage } from '@platform/transport/http'; +import { IconButton } from '@platform/ui/Button'; +import { Tooltip } from '@platform/ui/Tooltip'; +import { RotateCcwIcon } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { refreshQueue } from './queueDataStore'; +import { useQueueUi } from './QueueUiContext'; + +/** + * Re-queues a failed or canceled root item from its saved session; the backend reports which ids it took. A Canvas + * job whose project is closed here is staged by another tab, so the confirmation says where its result goes. + */ +export const RetryQueueItemButton = ({ + isProjectClosedHere = false, + itemId, +}: { + isProjectClosedHere?: boolean; + itemId: number; +}) => { + const { t } = useTranslation(); + const { notify } = useQueueUi(); + const [busy, setBusy] = useState(false); + + const onRetry = useCallback( + async (event: ReactMouseEvent) => { + event.stopPropagation(); + setBusy(true); + + try { + const retriedItemIds = await queueCommands.retryItems([itemId]); + await refreshQueue(); + if (retriedItemIds.length === 0) { + notify.info(t('widgets.queue.retryNotQueued'), t('widgets.queue.retryNotQueuedDescription', { id: itemId })); + } else { + notify.success( + t('widgets.queue.retryQueued'), + t( + isProjectClosedHere + ? 'widgets.queue.retryQueuedProjectClosedDescription' + : 'widgets.queue.retryQueuedDescription', + { id: itemId } + ) + ); + } + } catch (error) { + notify.error(t('widgets.queue.retryFailed'), getApiErrorMessage(error, t('widgets.queue.couldNotRetryItem'))); + } finally { + setBusy(false); + } + }, + [isProjectClosedHere, itemId, notify, t] + ); + + return ( + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/index.ts b/invokeai/frontend/webv2/src/features/queue/ui/index.ts index 7066b26cc0f..79691d72b53 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/index.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/index.ts @@ -1,4 +1,4 @@ -export { ModelCacheFooter } from './ModelCacheFooter'; +export { QueueFooter } from './QueueFooter'; export { QueueHeaderActions } from './QueueHeaderActions'; export { QueueHeaderLabel } from './QueueHeaderLabel'; export { QueueHeaderMenu } from './QueueHeaderMenu'; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts index 6ae74c4c078..1919824facc 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts @@ -1,6 +1,12 @@ -import type { QueueCounts, QueueItemReadModel, QueueQueryScope } from '@features/queue/core/types'; - -import { getQueueReadModelOptions } from '@features/queue/publicApi'; +import type { + QueueCounts, + QueueItemDetailReadModel, + QueueItemReadModel, + QueueQueryScope, +} from '@features/queue/core/types'; + +import { invalidateQueueQueries } from '@features/queue/data/queries'; +import { getQueueItemDetailOptions, getQueueReadModelOptions } from '@features/queue/publicApi'; import { queryClient } from '@platform/query/client'; import { useQuery } from '@tanstack/react-query'; @@ -38,9 +44,7 @@ const useQueueReadModel = () => { }; /** Invalidate queue data; TanStack Query deduplicates concurrent callers. */ -export const refreshQueue = async (): Promise => { - await queryClient.invalidateQueries({ queryKey: ['queue'] }); -}; +export const refreshQueue = (): Promise => invalidateQueueQueries(queryClient); /** Imperative prefetch for non-React composition roots. */ export const ensureQueueLoaded = (scope: QueueQueryScope = {}): void => { @@ -75,3 +79,41 @@ export const useQueueLoadState = (): { loadState: 'loading' | 'loaded' | 'error' return { error: null, loadState: query.isPending ? 'loading' : 'loaded' }; }; + +export interface QueueItemDetailState { + detail: QueueItemDetailReadModel | null; + error: string | null; + loadState: 'idle' | 'loading' | 'loaded' | 'error'; + retry: () => void; + /** A retry of a failed read is in flight; the failure stays shown until it settles. */ + retrying: boolean; +} + +/** One item's saved execution data, fetched only while `enabled` (a details dialog is open). */ +export const useQueueItemDetail = (itemId: number, enabled: boolean): QueueItemDetailState => { + const query = useQuery({ ...getQueueItemDetailOptions(itemId), enabled }); + const retry = query.refetch; + + if (!enabled) { + return { detail: null, error: null, loadState: 'idle', retry, retrying: false }; + } + + if (query.isError) { + return { + detail: null, + // The view words the failure; only a reason the server gave is worth adding to it. + error: query.error instanceof Error ? query.error.message : null, + loadState: 'error', + retry, + retrying: query.isFetching, + }; + } + + return { + detail: query.data ?? null, + error: null, + loadState: query.isPending ? 'loading' : 'loaded', + retry, + retrying: false, + }; +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts index 6f625359d9a..2fca64c6b7d 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts @@ -1,8 +1,9 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; +import { buildUtilityQueueItemOrigin } from '@features/queue/data/events'; import { describe, expect, it } from 'vitest'; -import { getQueueItemAccess } from './queueOwnership'; +import { getQueueItemAccess, isCancellableQueueItem, isRetryableQueueItem } from './queueOwnership'; const item = { userId: 'owner' } as QueueItemReadModel; @@ -31,3 +32,23 @@ describe('queue item ownership', () => { ); }); }); + +describe('queue item row actions', () => { + it('offers cancel to pending, waiting and running items only', () => { + expect(isCancellableQueueItem({ status: 'pending' })).toBe(true); + expect(isCancellableQueueItem({ status: 'waiting' })).toBe(true); + expect(isCancellableQueueItem({ status: 'in_progress' })).toBe(true); + expect(isCancellableQueueItem({ status: 'completed' })).toBe(false); + expect(isCancellableQueueItem({ status: 'failed' })).toBe(false); + expect(isCancellableQueueItem({ status: 'canceled' })).toBe(false); + }); + + it('offers retry to failed and canceled roots but not to workflow-call children', () => { + expect(isRetryableQueueItem({ parentItemId: null, status: 'failed' })).toBe(true); + expect(isRetryableQueueItem({ status: 'canceled' })).toBe(true); + expect(isRetryableQueueItem({ parentItemId: 3, status: 'failed' })).toBe(false); + expect(isRetryableQueueItem({ parentItemId: null, status: 'completed' })).toBe(false); + expect(isRetryableQueueItem({ parentItemId: null, status: 'pending' })).toBe(false); + expect(isRetryableQueueItem({ origin: buildUtilityQueueItemOrigin('thumbnail'), status: 'failed' })).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts index 8a2110c8fdf..51772e1025d 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts @@ -1,5 +1,7 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; +import { isUtilityQueueItemOrigin } from '@features/queue/data/events'; + export interface QueueViewer { currentUserId: string | null; isAdmin: boolean; @@ -19,3 +21,16 @@ export const getQueueItemAccess = (item: QueueItemReadModel, viewer: QueueViewer (viewer.currentUserId !== null && item.userId === viewer.currentUserId), canViewDetails: item.userId !== 'redacted', }); + +/** + * Only failed or canceled roots retry: the backend clones the root session, so a child offers nothing extra. Utility + * work (canvas filters, layer workflows) awaits its result in the caller that enqueued it, which a rerun cannot reach. + */ +export const isRetryableQueueItem = (item: Pick): boolean => + (item.status === 'failed' || item.status === 'canceled') && + (item.parentItemId === null || item.parentItemId === undefined) && + !isUtilityQueueItemOrigin(item.origin); + +/** Pending, waiting (a workflow-call parent) and running items can all be canceled. */ +export const isCancellableQueueItem = (item: Pick): boolean => + item.status === 'pending' || item.status === 'waiting' || item.status === 'in_progress'; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/statusMeta.ts b/invokeai/frontend/webv2/src/features/queue/ui/statusMeta.ts index f121e04280d..505273c1fef 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/statusMeta.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/statusMeta.ts @@ -12,7 +12,7 @@ const STATUS_META: Record = { failed: { colorPalette: 'red', dotColor: 'red.solid', labelKey: 'common.status.failed' }, in_progress: { colorPalette: 'accent', dotColor: 'accent.solid', labelKey: 'common.generating' }, pending: { colorPalette: 'gray', dotColor: 'fg.muted', labelKey: 'common.status.pending' }, - waiting: { colorPalette: 'gray', dotColor: 'fg.muted', labelKey: 'common.status.pending' }, + waiting: { colorPalette: 'gray', dotColor: 'fg.muted', labelKey: 'common.status.waiting' }, }; export const getStatusMeta = (status: QueueItemStatus): QueueStatusMeta => STATUS_META[status]; diff --git a/invokeai/frontend/webv2/src/features/queue/widget.ts b/invokeai/frontend/webv2/src/features/queue/widget.ts index 61dcbec706a..0b4c8c84b6a 100644 --- a/invokeai/frontend/webv2/src/features/queue/widget.ts +++ b/invokeai/frontend/webv2/src/features/queue/widget.ts @@ -1,7 +1,7 @@ /** Queue-owned deferred widget slots; Workbench supplies the manifest adapter. */ export const loadQueueWidgetImplementation = () => import('./ui').then((module) => ({ - footer: module.ModelCacheFooter, + footer: module.QueueFooter, headerActions: module.QueueHeaderActions, headerLabel: module.QueueHeaderLabel, headerMenu: module.QueueHeaderMenu, diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts index cde68b1cccb..fb616cb37d9 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts @@ -250,6 +250,50 @@ describe('getProjectGraphReadiness', () => { ).toEqual({ batch: null, canInvoke: false, reasons: ['"optional-constrained" has invalid input "count".'] }); }); + it('validates a form-exposed field against its authored bounds', () => { + const constrainedTemplate = template('form-bounded', { + count: input('count', { + maximum: 100, + minimum: 0, + required: false, + type: { batch: false, cardinality: 'SINGLE', name: 'IntegerField' }, + }), + }); + const snapshot = { error: null, status: 'loaded', templates: { 'form-bounded': constrainedTemplate } } as const; + const node = buildInvocationNode(constrainedTemplate, { x: 0, y: 0 }); + let doc = createProjectGraph('form-bounds'); + + doc = projectGraphReducer(doc, { node, type: 'addNode' }); + doc = projectGraphReducer(doc, { fieldName: 'count', nodeId: node.id, type: 'setFieldValue', value: 50 }); + doc = projectGraphReducer(doc, { fieldIdentifier: { fieldName: 'count', nodeId: node.id }, type: 'exposeField' }); + + const element = Object.values(doc.form.elements).find((candidate) => candidate.type === 'node-field'); + + expect(element).toBeDefined(); + expect(getProjectGraphReadiness(doc, snapshot).canInvoke).toBe(true); + + doc = projectGraphReducer(doc, { + elementId: element!.id, + settings: { component: 'slider', max: 20, min: 0, type: 'integer-field-config' }, + type: 'setNodeFieldSettings', + }); + + expect(getProjectGraphReadiness(doc, snapshot)).toEqual({ + batch: null, + canInvoke: false, + reasons: ['"form-bounded" has invalid input "count".'], + }); + + // An inverted pair is ignored rather than blocking the invoke. + doc = projectGraphReducer(doc, { + elementId: element!.id, + settings: { component: 'slider', max: 20, min: 30, type: 'integer-field-config' }, + type: 'setNodeFieldSettings', + }); + + expect(getProjectGraphReadiness(doc, snapshot).canInvoke).toBe(true); + }); + it('treats only explicitly externally satisfied connection inputs as ready', () => { const connectionTemplate = template('connection-sink', { image: input('image', { diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts index f99edcc98de..238efbb4b3d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts @@ -30,6 +30,7 @@ import { validateForLoopGraph, type ForLoopValidationReason, } from './forLoops'; +import { getFormFieldTemplate } from './formFieldSettings'; import { isInvocationNode } from './types'; import { hasAnyCycle } from './validation'; @@ -187,7 +188,7 @@ export const getProjectGraphReadiness = ( const invalidReason = getWorkflowFieldInvalidReason({ isConnected: false, - template: inputTemplate, + template: getFormFieldTemplate(document.form, node.id, inputTemplate.name, inputTemplate), value: node.data.inputs[inputTemplate.name]?.value, }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts index 39a84b79e48..d13d75c529f 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts @@ -702,3 +702,49 @@ describe('node updates', () => { expect(projectGraphReducer(next, { templates: { add: templateV110 }, type: 'updateNodes' })).toBe(next); }); }); + +describe('form field settings and arrangement', () => { + it('stores a form element settings bag verbatim and keeps the shuffle flag in step with it', () => { + const { doc, nodeAId } = createDocWithNodes(); + let next = projectGraphReducer(doc, { fieldIdentifier: { fieldName: 'a', nodeId: nodeAId }, type: 'exposeField' }); + const field = Object.values(next.form.elements).find((element) => element.type === 'node-field'); + const bag = { component: 'slider', max: 10, min: 1, showShuffle: false, type: 'integer-field-config' }; + + next = projectGraphReducer(next, { elementId: field?.id ?? '', settings: bag, type: 'setNodeFieldSettings' }); + next = projectGraphReducer(next, { + elementId: field?.id ?? '', + showShuffle: true, + type: 'setNodeFieldShowShuffle', + }); + + const updated = next.form.elements[field?.id ?? '']; + + expect(updated?.type === 'node-field' && updated.data.settings).toEqual({ ...bag, showShuffle: true }); + expect(updated?.type === 'node-field' && updated.data.showShuffle).toBe(true); + expect(getProjectGraphUndoEntry({ elementId: 'e', settings: bag, type: 'setNodeFieldSettings' })).toEqual({ + label: 'Edit workflow form', + mergeKey: 'setNodeFieldSettings:e', + }); + }); + + it('moves several nodes in one undoable step and leaves the document alone when nothing moves', () => { + const { doc } = createDocWithNodes(); + const [first, second] = doc.nodes; + const next = projectGraphReducer(doc, { + positions: { [first!.id]: { x: 10, y: 20 }, [second!.id]: { x: 30, y: 40 }, missing: { x: 0, y: 0 } }, + type: 'setNodePositions', + }); + + expect(next.nodes.map((node) => node.position)).toEqual([ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ...doc.nodes.slice(2).map((node) => node.position), + ]); + expect(getProjectGraphUndoEntry({ positions: {}, type: 'setNodePositions' })).toEqual({ + label: 'Arrange workflow nodes', + }); + expect(projectGraphReducer(next, { positions: { [first!.id]: { x: 10, y: 20 } }, type: 'setNodePositions' })).toBe( + next + ); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.ts index 76b40f9524b..96f334d17f5 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.ts @@ -675,6 +675,8 @@ export type ProjectGraphAction = | { type: 'addGraphElements'; nodes: WorkflowNode[]; edges: WorkflowEdge[] } | { type: 'removeNodes'; nodeIds: string[] } | { type: 'setNodePosition'; nodeId: string; position: XYPosition } + /** Moves several nodes at once as one undoable step (an automatic arrangement); unknown ids are ignored. */ + | { type: 'setNodePositions'; positions: Record } | { type: 'setNodeLabel'; nodeId: string; label: string } | { type: 'setNodeNotes'; nodeId: string; notes: string } | { type: 'setNodeIsOpen'; nodeId: string; isOpen: boolean } @@ -715,6 +717,8 @@ export type ProjectGraphAction = | { type: 'setFormElementContent'; elementId: string; content: string } | { type: 'setNodeFieldShowDescription'; elementId: string; showDescription: boolean } | { type: 'setNodeFieldShowShuffle'; elementId: string; showShuffle: boolean } + /** Replaces the element's control settings bag, already serialized in the legacy shape by the caller. */ + | { type: 'setNodeFieldSettings'; elementId: string; settings: Record } | { type: 'setContainerLayout'; elementId: string; layout: 'row' | 'column' } | { type: 'setMetadata'; patch: Partial } /** A committed rename (a dialog, an adopted template name): one undo step of its own, unlike typing a name. */ @@ -742,7 +746,9 @@ const undoLabels: Partial> = { setFormElementContent: 'Edit workflow form', setMetadata: 'Edit workflow details', setNodeFieldShowDescription: 'Edit workflow form', + setNodeFieldSettings: 'Edit workflow form', setNodeFieldShowShuffle: 'Edit workflow form', + setNodePositions: 'Arrange workflow nodes', setNodeIsIntermediate: 'Change workflow node output saving', setNodeLabel: 'Rename workflow node', setNodeNotes: 'Edit workflow node notes', @@ -790,6 +796,7 @@ const getUndoMergeKey = (action: ProjectGraphAction): string | undefined => { case 'setNodeNotes': return `${action.type}:${action.nodeId}`; case 'setFormElementContent': + case 'setNodeFieldSettings': return `${action.type}:${action.elementId}`; case 'setMetadata': return `${action.type}:${Object.keys(action.patch).sort().join(',')}`; @@ -956,6 +963,22 @@ const applyProjectGraphAction = (document: ProjectGraphState, action: ProjectGra case 'setNodePosition': { return updateNode(document, action.nodeId, (node) => ({ ...node, position: { ...action.position } })); } + case 'setNodePositions': { + let moved = false; + const nodes = document.nodes.map((node) => { + const position = action.positions[node.id]; + + if (!position || (position.x === node.position.x && position.y === node.position.y)) { + return node; + } + + moved = true; + + return { ...node, position: { x: position.x, y: position.y } }; + }); + + return moved ? { ...document, nodes } : document; + } case 'updateNodes': { return updateWorkflowNodes(document, action.templates, action.nodeIds).document; } @@ -1165,6 +1188,9 @@ const applyProjectGraphAction = (document: ProjectGraphState, action: ProjectGra }, }; } + case 'setNodeFieldSettings': { + return patchNodeFieldElement(document, action.elementId, { settings: { ...action.settings } }); + } case 'setContainerLayout': { const element = document.form.elements[action.elementId]; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts index dd797305848..db8f90f146c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts @@ -138,6 +138,15 @@ describe('workflow field validation', () => { expect(isWorkflowFieldValueValid(input({ multipleOf: 2, type: single('IntegerField') }), -3)).toBe(false); }); + it('accepts multiples of a fine step far from zero despite float noise', () => { + // 1.001 / 0.001 is 1000.9999999999999; an absolute tolerance on the quotient would reject it. + const fine = input({ maximum: 2, minimum: 1, multipleOf: 0.001, type: single('FloatField') }); + + expect(isWorkflowFieldValueValid(fine, 1.001)).toBe(true); + expect(isWorkflowFieldValueValid(fine, 1.999)).toBe(true); + expect(isWorkflowFieldValueValid(fine, 1.0015)).toBe(false); + }); + it('tells a cleared required number apart from a present but invalid one', () => { const required = input({ minimum: 0, type: single('IntegerField') }); const optional = input({ required: false, type: single('FloatField') }); @@ -436,6 +445,10 @@ describe('getRandomWorkflowFieldValue', () => { const decimal = input({ maximum: 1, minimum: 0, multipleOf: 0.1, type: single('FloatField') }); expect(getRandomWorkflowFieldValue(decimal, () => 0.3)).toBe(0.3); + + const tiny = input({ maximum: 9e-7, minimum: 1e-7, multipleOf: 1e-7, type: single('FloatField') }); + + expect(getRandomWorkflowFieldValue(tiny, () => 0.5)).toBe(5e-7); }); it('respects exclusive bounds for integers and keeps floats unrounded', () => { @@ -459,6 +472,25 @@ describe('getRandomWorkflowFieldValue', () => { expect(getRandomWorkflowFieldValue(steppedExclusive, () => 0.999)).toBe(0.5); }); + it('treats a bound that is a multiple up to float noise the way validation does', () => { + // 1.1 / 0.1 is 11.000000000000002 and 0.3 / 0.1 is 2.9999999999999996; validation accepts both as multiples. + const closed = input({ maximum: 1.1, minimum: 0.3, multipleOf: 0.1, type: single('FloatField') }); + + expect(getRandomWorkflowFieldValue(closed, () => 0)).toBe(0.3); + expect(getRandomWorkflowFieldValue(closed, () => 0.999)).toBe(1.1); + + const open = input({ exclusiveMaximum: 1.1, exclusiveMinimum: 0.3, multipleOf: 0.1, type: single('FloatField') }); + + expect(getRandomWorkflowFieldValue(open, () => 0)).toBe(0.4); + expect(getRandomWorkflowFieldValue(open, () => 0.999)).toBe(1); + expect( + isWorkflowFieldValueValid( + open, + getRandomWorkflowFieldValue(open, () => 0) + ) + ).toBe(true); + }); + it('keeps legacy numeric templates finite when optional constraints are absent', () => { const legacy = input({ exclusiveMaximum: undefined, @@ -475,10 +507,14 @@ describe('getRandomWorkflowFieldValue', () => { expect(isWorkflowFieldValueValid(legacy, value)).toBe(true); }); - it('shuffles only direct numeric fields', () => { + it('shuffles only direct numeric fields with a value in range', () => { expect(isShuffleableField(input({ type: single('IntegerField') }))).toBe(true); expect(isShuffleableField(input({ type: single('FloatField') }))).toBe(true); expect(isShuffleableField(input({ input: 'connection', type: single('IntegerField') }))).toBe(false); expect(isShuffleableField(input())).toBe(false); + // No multiple of 8 lies between 1 and 7, so any value a shuffle wrote would be invalid. + expect(isShuffleableField(input({ maximum: 7, minimum: 1, multipleOf: 8, type: single('IntegerField') }))).toBe( + false + ); }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts index aa28f89ceda..11bfa7d7920 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts @@ -71,56 +71,121 @@ export const isDirectInputField = (template: FieldInputTemplate): boolean => /** A field can be exposed to the Linear UI when it can be edited directly. */ export const isExposableField = (template: FieldInputTemplate): boolean => isDirectInputField(template); -/** Scalar numeric fields whose linear-form element can show a randomize button. */ +/** Direct numeric fields whose bounds admit at least one value; shuffling any other would write an invalid one. */ export const isShuffleableField = (template: FieldInputTemplate): boolean => (template.type.name === 'IntegerField' || template.type.name === 'FloatField') && template.type.cardinality !== 'COLLECTION' && - isDirectInputField(template); + isDirectInputField(template) && + admitsWorkflowFieldStepValue(template); +/** Digits after the point in `value`'s shortest form, counting those its exponent implies: 1.5e-7 has eight. */ const countDecimals = (value: number): number => { - const [, fraction = ''] = String(value).split('.'); + const [mantissa = '', exponent = '0'] = String(value).split('e'); + const [, fraction = ''] = mantissa.split('.'); - return fraction.length; + // `toFixed` accepts at most 100 digits. + return Math.min(100, Math.max(0, fraction.length - Number(exponent))); }; -const finiteNumberOrNull = (value: number | null | undefined): number | null => +export const finiteNumberOrNull = (value: number | null | undefined): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; +/** The integer `value / step` is a multiple at, or null when it is not a multiple. Validation and bounds share it. */ +const exactStepIndex = (value: number, step: number): number | null => { + const quotient = value / step; + const nearest = Math.round(quotient); + // How far the quotient may sit from an integer and still count, relative to its size so float noise scales with + // it: 0.3 / 0.1 is 2.9999…, and 1.001 / 0.001 is 1000.9999…; both are multiples. Evaluated here rather than at + // module scope: a top-level global member access marks this module side-effectful, which keeps the workflow + // barrels that re-export through it from being tree-shaken and pulls their other chunks into the Launchpad. + const tolerance = Number.EPSILON * 100 * Math.max(1, Math.abs(quotient)); + + return Math.abs(quotient - nearest) <= tolerance ? nearest : null; +}; + +const stepIndex = (value: number, step: number, round: (quotient: number) => number): number => + exactStepIndex(value, step) ?? round(value / step); + +export interface WorkflowFieldStepIndexBounds { + /** Index of the first multiple of the step the bounds admit; null while the lower end is open. */ + first: number | null; + /** Index of the last multiple of the step the bounds admit; null while the upper end is open. */ + last: number | null; +} + +/** + * Which multiples of `step` (counted from zero, as validation counts them) lie inside the template's bounds, as the + * indices of the first and last. Shuffle and the slider track both read this, so neither offers a value the node + * would reject: a field stepping by 8 between 1 and 100 admits 8…96, and one stepping by 8 between 1 and 7 none + * (`first` exceeds `last`). + */ +export const getWorkflowFieldStepIndexBounds = ( + template: FieldInputTemplate, + step: number +): WorkflowFieldStepIndexBounds => { + const exclusiveMaximum = finiteNumberOrNull(template.exclusiveMaximum); + const exclusiveMinimum = finiteNumberOrNull(template.exclusiveMinimum); + const maximum = finiteNumberOrNull(template.maximum); + const minimum = finiteNumberOrNull(template.minimum); + + return { + first: + minimum !== null + ? stepIndex(minimum, step, Math.ceil) + : exclusiveMinimum !== null + ? stepIndex(exclusiveMinimum, step, Math.floor) + 1 + : null, + last: + maximum !== null + ? stepIndex(maximum, step, Math.floor) + : exclusiveMaximum !== null + ? stepIndex(exclusiveMaximum, step, Math.ceil) - 1 + : null, + }; +}; + +/** + * False when the bounds admit no multiple of the field's step (every integer, for an integer field without one), so + * no value passes validation: a field stepping by 8 between 1 and 7. Fields without a step admit any value in range. + */ +export const admitsWorkflowFieldStepValue = (template: FieldInputTemplate): boolean => { + const multipleOf = finiteNumberOrNull(template.multipleOf); + const step = multipleOf !== null && multipleOf > 0 ? multipleOf : template.type.name === 'IntegerField' ? 1 : null; + + if (step === null) { + return true; + } + + const { first, last } = getWorkflowFieldStepIndexBounds(template, step); + + return first === null || last === null || first <= last; +}; + +/** The multiple of `step` at `index`, written at the step's own precision so three tenths reads 0.3. */ +export const toWorkflowFieldStepMultiple = (index: number, step: number): number => + Number((index * step).toFixed(countDecimals(step))); + /** A random value inside the template's bounds, snapped to its step; unbounded ends default to 0…SEED_MAX. */ export const getRandomWorkflowFieldValue = (template: FieldInputTemplate, random = Math.random): number => { const isInteger = template.type.name === 'IntegerField'; const multipleOf = finiteNumberOrNull(template.multipleOf); const step = multipleOf !== null && multipleOf > 0 ? multipleOf : isInteger ? 1 : 0; - const exclusiveMaximum = finiteNumberOrNull(template.exclusiveMaximum); - const exclusiveMinimum = finiteNumberOrNull(template.exclusiveMinimum); - const maximum = finiteNumberOrNull(template.maximum); - const minimum = finiteNumberOrNull(template.minimum); if (step <= 0) { - const min = minimum ?? exclusiveMinimum ?? 0; - const max = maximum ?? exclusiveMaximum ?? SEED_MAX; + const min = finiteNumberOrNull(template.minimum) ?? finiteNumberOrNull(template.exclusiveMinimum) ?? 0; + const max = finiteNumberOrNull(template.maximum) ?? finiteNumberOrNull(template.exclusiveMaximum) ?? SEED_MAX; return min + random() * (max - min); } // Draw among the step multiples that lie inside the bounds (exclusive ends excluded), so the // result is always valid. - const decimals = countDecimals(step); - const first = - minimum !== null - ? Math.ceil(minimum / step) - : exclusiveMinimum !== null - ? Math.floor(exclusiveMinimum / step) + 1 - : 0; - const last = - maximum !== null - ? Math.floor(maximum / step) - : exclusiveMaximum !== null - ? Math.ceil(exclusiveMaximum / step) - 1 - : Math.floor(SEED_MAX / step); + const bounds = getWorkflowFieldStepIndexBounds(template, step); + const first = bounds.first ?? 0; + const last = bounds.last ?? Math.floor(SEED_MAX / step); const index = first + Math.floor(random() * (last - first + 1)); - return Number((Math.min(last, index) * step).toFixed(decimals)); + return toWorkflowFieldStepMultiple(Math.min(last, index), step); }; export const cloneWorkflowFieldDefault = (template: FieldInputTemplate): unknown => @@ -245,15 +310,7 @@ const isNumberFieldValueValid = (template: FieldInputTemplate, value: unknown): const multipleOf = finiteNumberOrNull(template.multipleOf); - if (multipleOf !== null && multipleOf > 0) { - const quotient = value / multipleOf; - - if (Math.abs(quotient - Math.round(quotient)) > Number.EPSILON * 100) { - return false; - } - } - - return true; + return multipleOf === null || multipleOf <= 0 || exactStepIndex(value, multipleOf) !== null; }; const isStringFieldValueValid = (template: FieldInputTemplate, value: unknown): boolean => { diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts new file mode 100644 index 00000000000..7aa8fee2c6a --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from 'vitest'; + +import type { FieldInputTemplate } from './types'; + +import { + applyNodeFieldFormSettings, + getDefaultNodeFieldFormSettings, + getNodeFieldFormSettings, + getNodeFieldSettingsIssue, + getNodeFieldSliderRange, + serializeNodeFieldFormSettings, +} from './formFieldSettings'; + +const template = ( + typeName: 'FloatField' | 'IntegerField' | 'StringField', + overrides: Partial = {} +): FieldInputTemplate => ({ + default: undefined, + description: '', + exclusiveMaximum: null, + exclusiveMinimum: null, + fieldKind: 'input', + input: 'any', + maximum: null, + minimum: null, + multipleOf: null, + name: 'value', + options: null, + required: true, + title: 'Value', + type: { batch: false, cardinality: 'SINGLE', name: typeName }, + uiChoiceLabels: null, + uiComponent: null, + uiHidden: false, + uiModelBase: null, + uiModelFormat: null, + uiModelType: null, + uiOrder: null, + ...overrides, +}); + +describe('getNodeFieldFormSettings', () => { + it('reads a legacy integer bag with its control and bounds', () => { + const settings = getNodeFieldFormSettings( + { component: 'number-input-and-slider', max: 50, min: 1, showShuffle: true, type: 'integer-field-config' }, + template('IntegerField') + ); + + expect(settings).toEqual({ component: 'number-input-and-slider', kind: 'number', max: 50, min: 1 }); + }); + + it('reads a legacy dropdown bag and tolerates malformed options', () => { + const settings = getNodeFieldFormSettings( + { + component: 'dropdown', + options: [{ label: 'Fast', value: 'fast' }, { value: 'slow' }, 'junk', null], + type: 'string-field-config', + }, + template('StringField') + ); + + expect(settings).toEqual({ + component: 'dropdown', + kind: 'string', + options: [ + { label: 'Fast', value: 'fast' }, + { label: '', value: 'slow' }, + ], + }); + }); + + it('ignores a bag tagged for another field kind and bags on fields that cannot carry one', () => { + expect( + getNodeFieldFormSettings({ component: 'slider', type: 'float-field-config' }, template('IntegerField')) + ).toBeNull(); + expect( + getNodeFieldFormSettings( + { component: 'slider', type: 'integer-field-config' }, + template('IntegerField', { type: { batch: false, cardinality: 'COLLECTION', name: 'IntegerField' } }) + ) + ).toBeNull(); + expect(getNodeFieldFormSettings(undefined, template('IntegerField'))).toBeNull(); + }); + + it('keeps inverted bounds as authored and drops a non-positive step', () => { + expect( + getNodeFieldFormSettings({ component: 'slider', max: 1, min: 10, step: -2 }, template('FloatField')) + ).toEqual({ component: 'slider', kind: 'number', max: 1, min: 10 }); + }); + + it('falls back to the plain control for an unknown component name', () => { + expect(getNodeFieldFormSettings({ component: 'knob' }, template('FloatField'))).toEqual({ + component: 'number-input', + kind: 'number', + }); + expect(getNodeFieldFormSettings({ component: 'rich-text' }, template('StringField'))).toEqual({ + component: 'input', + kind: 'string', + options: [], + }); + }); +}); + +describe('getDefaultNodeFieldFormSettings', () => { + it('starts a prompt-like string field as a text area and numbers as inputs', () => { + expect(getDefaultNodeFieldFormSettings(template('StringField', { uiComponent: 'textarea' }))).toEqual({ + component: 'textarea', + kind: 'string', + options: [], + }); + expect(getDefaultNodeFieldFormSettings(template('FloatField'))).toEqual({ + component: 'number-input', + kind: 'number', + }); + }); +}); + +describe('serializeNodeFieldFormSettings', () => { + it('writes the legacy shape the v6 editor reads back, round-tripping through the reader', () => { + const numberBag = serializeNodeFieldFormSettings( + { component: 'slider', kind: 'number', max: 8, min: 2, step: 2 }, + template('IntegerField'), + true + ); + + expect(numberBag).toEqual({ + component: 'slider', + max: 8, + min: 2, + showShuffle: true, + step: 2, + type: 'integer-field-config', + }); + expect(getNodeFieldFormSettings(numberBag, template('IntegerField'))).toEqual({ + component: 'slider', + kind: 'number', + max: 8, + min: 2, + step: 2, + }); + + const stringBag = serializeNodeFieldFormSettings( + { component: 'dropdown', kind: 'string', options: [{ label: 'A', value: 'a' }] }, + template('StringField'), + false + ); + + expect(stringBag).toEqual({ + component: 'dropdown', + options: [{ label: 'A', value: 'a' }], + type: 'string-field-config', + }); + }); + + it('does not keep dropdown options on a field switched back to a text input', () => { + expect( + serializeNodeFieldFormSettings( + { component: 'input', kind: 'string', options: [] }, + template('StringField'), + false + ) + ).toEqual({ component: 'input', type: 'string-field-config' }); + }); + + it('keeps keys of the stored bag it does not own and drops owned ones the edit cleared', () => { + expect( + serializeNodeFieldFormSettings({ component: 'slider', kind: 'number', max: 8 }, template('IntegerField'), false, { + component: 'slider', + max: 8, + min: 2, + showShuffle: true, + sliderMarks: [2, 8], + type: 'integer-field-config', + }) + ).toEqual({ component: 'slider', max: 8, showShuffle: false, sliderMarks: [2, 8], type: 'integer-field-config' }); + expect( + serializeNodeFieldFormSettings( + { component: 'input', kind: 'string', options: [] }, + template('StringField'), + false, + { + component: 'dropdown', + options: [{ label: 'A', value: 'a' }], + placeholder: 'Pick one', + type: 'string-field-config', + } + ) + ).toEqual({ component: 'input', placeholder: 'Pick one', type: 'string-field-config' }); + }); +}); + +describe('applyNodeFieldFormSettings', () => { + it('narrows the template to the authored bounds but never widens them', () => { + const base = template('IntegerField', { maximum: 100, minimum: 0 }); + + expect(applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', max: 200, min: 10 })).toMatchObject({ + maximum: 100, + minimum: 10, + }); + }); + + it('replaces an exclusive template bound only with a tighter inclusive one', () => { + const base = template('FloatField', { exclusiveMinimum: 0 }); + + expect(applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', min: 0.5 })).toMatchObject({ + exclusiveMinimum: null, + minimum: 0.5, + }); + expect(applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', min: -1 })).toBe(base); + }); + + it('applies a step only where it keeps every value valid for the node', () => { + const free = template('FloatField'); + const eights = template('IntegerField', { multipleOf: 8 }); + + expect(applyNodeFieldFormSettings(free, { component: 'slider', kind: 'number', step: 0.25 }).multipleOf).toBe(0.25); + expect(applyNodeFieldFormSettings(eights, { component: 'slider', kind: 'number', step: 64 }).multipleOf).toBe(64); + expect(applyNodeFieldFormSettings(eights, { component: 'slider', kind: 'number', step: 12 })).toBe(eights); + // An integer field cannot take a fractional step: the slider would commit values the node rejects. + const whole = template('IntegerField'); + expect(applyNodeFieldFormSettings(whole, { component: 'slider', kind: 'number', step: 0.5 })).toBe(whole); + }); + + it('ignores an inverted pair of bounds but still applies the step', () => { + const base = template('IntegerField', { maximum: 100, minimum: 0 }); + + expect(applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', max: 5, min: 10 })).toBe(base); + expect( + applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', max: 5, min: 10, step: 5 }) + ).toMatchObject({ maximum: 100, minimum: 0, multipleOf: 5 }); + }); + + it("ignores a bound beyond the template's other end, which would leave no valid value", () => { + const closed = template('IntegerField', { maximum: 10, minimum: 0 }); + + expect(applyNodeFieldFormSettings(closed, { component: 'slider', kind: 'number', min: 20 })).toBe(closed); + expect(applyNodeFieldFormSettings(closed, { component: 'slider', kind: 'number', max: -5 })).toBe(closed); + // Either end may be reached exactly, which leaves a single valid value. + expect(applyNodeFieldFormSettings(closed, { component: 'slider', kind: 'number', min: 10 })).toMatchObject({ + maximum: 10, + minimum: 10, + }); + // An exclusive end admits values up to, not at, the bound. + const open = template('FloatField', { exclusiveMaximum: 10, exclusiveMinimum: 0 }); + + expect(applyNodeFieldFormSettings(open, { component: 'slider', kind: 'number', min: 10 })).toBe(open); + expect(applyNodeFieldFormSettings(open, { component: 'slider', kind: 'number', max: 0 })).toBe(open); + expect(applyNodeFieldFormSettings(open, { component: 'slider', kind: 'number', max: 9.5, min: 0.5 })).toMatchObject( + { exclusiveMaximum: null, exclusiveMinimum: null, maximum: 9.5, minimum: 0.5 } + ); + }); + + it('returns the template itself for string settings and unchanged bounds', () => { + const base = template('StringField'); + + expect(applyNodeFieldFormSettings(base, { component: 'dropdown', kind: 'string', options: [] })).toBe(base); + expect(applyNodeFieldFormSettings(base, null)).toBe(base); + }); +}); + +describe('getNodeFieldSliderRange', () => { + it('derives a closed range with the template step, stepping inside exclusive ends', () => { + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 10, minimum: 1 }))).toEqual({ + max: 10, + min: 1, + step: 1, + }); + expect( + getNodeFieldSliderRange(template('FloatField', { exclusiveMaximum: 1, exclusiveMinimum: 0, multipleOf: 0.1 })) + ).toEqual({ max: 0.9, min: 0.1, step: 0.1 }); + expect(getNodeFieldSliderRange(template('FloatField', { exclusiveMaximum: 1, exclusiveMinimum: 0 }))).toEqual({ + max: 0.99, + min: 0.01, + step: 0.01, + }); + }); + + it('ends the track on the step multiples validation accepts, not on the bounds', () => { + // Every stop must pass `isWorkflowFieldValueValid`, which counts multiples from zero. + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 100, minimum: 1, multipleOf: 8 }))).toEqual({ + max: 96, + min: 8, + step: 8, + }); + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 9.5, minimum: 1.5 }))).toEqual({ + max: 9, + min: 2, + step: 1, + }); + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 0.95, minimum: 0.05, multipleOf: 0.1 }))).toEqual({ + max: 0.9, + min: 0.1, + step: 0.1, + }); + }); + + it('keeps the precision of a step written in exponent form', () => { + // String(1e-7) is "1e-7": rounding to its digits after the point alone would collapse every stop to 0. + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 9e-7, minimum: 1e-7, multipleOf: 1e-7 }))).toEqual( + { max: 9e-7, min: 1e-7, step: 1e-7 } + ); + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 3e-7, minimum: 0, multipleOf: 1.5e-7 }))).toEqual({ + max: 3e-7, + min: 0, + step: 1.5e-7, + }); + }); + + it('keeps a bound that is itself a multiple despite float noise, and steps past an exclusive one', () => { + // 1.1 / 0.1 and 0.3 / 0.1 are not integers in floating point; validation tolerates that and so must the track. + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 1.1, minimum: 0.3, multipleOf: 0.1 }))).toEqual({ + max: 1.1, + min: 0.3, + step: 0.1, + }); + expect( + getNodeFieldSliderRange(template('FloatField', { exclusiveMaximum: 1.1, exclusiveMinimum: 0.3, multipleOf: 0.1 })) + ).toEqual({ max: 1, min: 0.4, step: 0.1 }); + }); + + it('has no range while an end is open or no stop would be valid', () => { + expect(getNodeFieldSliderRange(template('IntegerField', { minimum: 0 }))).toBeNull(); + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 7, minimum: 1, multipleOf: 8 }))).toBeNull(); + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 2.9, minimum: 2.1 }))).toBeNull(); + }); + + it('has no range when the bounds admit a single stop, which a track could not move between', () => { + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 8, minimum: 8 }))).toBeNull(); + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 12, minimum: 5, multipleOf: 8 }))).toBeNull(); + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 0.5, minimum: 0.5 }))).toBeNull(); + }); +}); + +describe('getNodeFieldSettingsIssue', () => { + const closed = template('IntegerField', { maximum: 100, minimum: 0 }); + + it('names the problem the author has to fix first', () => { + expect(getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', max: 5, min: 10 })).toBe( + 'inverted' + ); + expect(getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', min: 200 })).toBe( + 'outOfRange' + ); + // A fractional step on an integer field, or one off the node's own step, is ignored rather than applied. + expect(getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', step: 2.5 })).toBe( + 'stepIgnored' + ); + const halves = template('FloatField', { maximum: 10, minimum: 0, multipleOf: 0.5 }); + expect(getNodeFieldSettingsIssue(halves, { component: 'number-input', kind: 'number', step: 0.3 })).toBe( + 'stepIgnored' + ); + expect(getNodeFieldSettingsIssue(halves, { component: 'number-input', kind: 'number', step: 1.5 })).toBeNull(); + // Bounds and step are each valid, but no multiple of 8 lies between 1 and 7: every value fails validation. + expect( + getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', max: 7, min: 1, step: 8 }) + ).toBe('noValue'); + expect(getNodeFieldSettingsIssue(closed, { component: 'slider', kind: 'number', max: 8, min: 8 })).toBe('noTrack'); + }); + + it('reports nothing for settings the form honours, including an input on an open-ended field', () => { + expect(getNodeFieldSettingsIssue(closed, { component: 'slider', kind: 'number', max: 50, min: 10 })).toBeNull(); + // Bounds that do not tighten the template are not applied, but they leave the range intact. + expect(getNodeFieldSettingsIssue(closed, { component: 'slider', kind: 'number', max: 500, min: -5 })).toBeNull(); + expect( + getNodeFieldSettingsIssue(template('IntegerField', { minimum: 0 }), { component: 'number-input', kind: 'number' }) + ).toBeNull(); + expect( + getNodeFieldSettingsIssue(template('StringField'), { component: 'dropdown', kind: 'string', options: [] }) + ).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts new file mode 100644 index 00000000000..e57f1a9ab12 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts @@ -0,0 +1,375 @@ +import type { FieldInputTemplate, WorkflowForm } from './types'; + +import { + admitsWorkflowFieldStepValue, + finiteNumberOrNull, + getWorkflowFieldStepIndexBounds, + toWorkflowFieldStepMultiple, +} from './fields'; + +/** + * Per-element control settings an author gives a form field: which control renders it and, for numbers, the + * bounds the form offers. They are stored in the element's legacy settings bag (`NodeFieldFormElement.data.settings`) + * in the shape the v6 editor wrote, so a workflow authored in either editor opens the same in the other. + */ + +export type NumberFieldComponent = 'number-input' | 'slider' | 'number-input-and-slider'; +export type StringFieldComponent = 'input' | 'textarea' | 'dropdown'; + +export interface NodeFieldStringOption { + label: string; + value: string; +} + +export interface NodeFieldNumberSettings { + kind: 'number'; + component: NumberFieldComponent; + /** + * Authored bounds and step; each applies only where it tightens the invocation template's own constraint without + * leaving its range. + */ + min?: number; + max?: number; + step?: number; +} + +export interface NodeFieldStringSettings { + kind: 'string'; + component: StringFieldComponent; + /** The choices a dropdown offers; ignored by the other controls. */ + options: NodeFieldStringOption[]; +} + +export type NodeFieldFormSettings = NodeFieldNumberSettings | NodeFieldStringSettings; + +export const NUMBER_FIELD_COMPONENTS: readonly NumberFieldComponent[] = [ + 'number-input', + 'slider', + 'number-input-and-slider', +]; +export const STRING_FIELD_COMPONENTS: readonly StringFieldComponent[] = ['input', 'textarea', 'dropdown']; + +// The v6 editor tags each bag with the field kind it was authored for. +const LEGACY_SETTINGS_TYPES = { + FloatField: 'float-field-config', + IntegerField: 'integer-field-config', + StringField: 'string-field-config', +} as const; + +type SettingsFieldTypeName = keyof typeof LEGACY_SETTINGS_TYPES; + +const isSettingsFieldTypeName = (name: string): name is SettingsFieldTypeName => name in LEGACY_SETTINGS_TYPES; + +/** True for the scalar fields a form element can carry control settings for. */ +export const hasNodeFieldFormSettings = (template: FieldInputTemplate): boolean => + template.type.cardinality === 'SINGLE' && isSettingsFieldTypeName(template.type.name); + +const finiteOrUndefined = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined; + +const isNumberComponent = (value: unknown): value is NumberFieldComponent => + typeof value === 'string' && (NUMBER_FIELD_COMPONENTS as readonly string[]).includes(value); + +const isStringComponent = (value: unknown): value is StringFieldComponent => + typeof value === 'string' && (STRING_FIELD_COMPONENTS as readonly string[]).includes(value); + +const toOptions = (value: unknown): NodeFieldStringOption[] => + Array.isArray(value) + ? value.flatMap((option) => + typeof option === 'object' && option !== null + ? [ + { + label: + typeof (option as { label?: unknown }).label === 'string' ? (option as { label: string }).label : '', + value: + typeof (option as { value?: unknown }).value === 'string' ? (option as { value: string }).value : '', + }, + ] + : [] + ) + : []; + +/** The control settings the template's own metadata implies, used until an author chooses otherwise. */ +export const getDefaultNodeFieldFormSettings = (template: FieldInputTemplate): NodeFieldFormSettings | null => { + if (!hasNodeFieldFormSettings(template)) { + return null; + } + + return template.type.name === 'StringField' + ? { component: template.uiComponent === 'textarea' ? 'textarea' : 'input', kind: 'string', options: [] } + : { component: 'number-input', kind: 'number' }; +}; + +/** + * The authored settings of a form element for `template`, or null when the element has none for this kind of + * field. A bag tagged for another field kind (the field's type changed under it) is ignored rather than misread; + * a bag with unusable bounds keeps its control choice and drops the bounds. + */ +export const getNodeFieldFormSettings = ( + settings: Record | undefined, + template: FieldInputTemplate +): NodeFieldFormSettings | null => { + if (!settings || !hasNodeFieldFormSettings(template)) { + return null; + } + + const typeName = template.type.name as SettingsFieldTypeName; + + if (settings.type !== undefined && settings.type !== LEGACY_SETTINGS_TYPES[typeName]) { + return null; + } + + if (typeName === 'StringField') { + const component = isStringComponent(settings.component) ? settings.component : 'input'; + + return { component, kind: 'string', options: component === 'dropdown' ? toOptions(settings.options) : [] }; + } + + const component = isNumberComponent(settings.component) ? settings.component : 'number-input'; + const min = finiteOrUndefined(settings.min); + const max = finiteOrUndefined(settings.max); + const step = finiteOrUndefined(settings.step); + + // Bounds are kept as authored, even inverted, so the builder edits what is stored; `applyNodeFieldFormSettings` + // decides what the form honours. + return { + component, + kind: 'number', + ...(min !== undefined ? { min } : {}), + ...(max !== undefined ? { max } : {}), + ...(step !== undefined && step > 0 ? { step } : {}), + }; +}; + +/** Authored bounds that cannot form a range; the form ignores both until the author fixes one. */ +const hasInvertedNodeFieldBounds = (settings: NodeFieldFormSettings | null): boolean => + settings?.kind === 'number' && + settings.min !== undefined && + settings.max !== undefined && + settings.min > settings.max; + +// The bag keys these settings own; a cleared one is left out rather than carried over from the stored bag. +const OWNED_SETTINGS_KEYS = new Set(['component', 'max', 'min', 'options', 'showShuffle', 'step', 'type']); + +/** + * The settings bag to store for `settings` on a field of `template`, in the legacy shape: tagged with the field + * kind, and carrying `showShuffle` on number fields as the v6 editor does. Keys of `stored` these settings do not + * own (another editor's) are kept. + */ +export const serializeNodeFieldFormSettings = ( + settings: NodeFieldFormSettings, + template: FieldInputTemplate, + showShuffle: boolean, + stored?: Record +): Record => { + const type = isSettingsFieldTypeName(template.type.name) ? LEGACY_SETTINGS_TYPES[template.type.name] : undefined; + const unowned = stored + ? Object.fromEntries(Object.entries(stored).filter(([key]) => !OWNED_SETTINGS_KEYS.has(key))) + : {}; + + if (settings.kind === 'string') { + return { + ...unowned, + component: settings.component, + type, + ...(settings.component === 'dropdown' ? { options: settings.options.map((option) => ({ ...option })) } : {}), + }; + } + + return { + ...unowned, + component: settings.component, + showShuffle, + type, + ...(settings.min !== undefined ? { min: settings.min } : {}), + ...(settings.max !== undefined ? { max: settings.max } : {}), + ...(settings.step !== undefined ? { step: settings.step } : {}), + }; +}; + +const isMultipleOf = (step: number, base: number): boolean => { + const ratio = step / base; + + return Math.abs(ratio - Math.round(ratio)) < 1e-9; +}; + +// Whether an authored bound lies inside the template's opposite end. One beyond it (a minimum above the template's +// maximum) would leave no value in range, so the form ignores it as it does an inverted pair. +const isMinimumInsideTemplate = (template: FieldInputTemplate, min: number): boolean => { + const maximum = finiteNumberOrNull(template.maximum); + const exclusiveMaximum = finiteNumberOrNull(template.exclusiveMaximum); + + return maximum !== null ? min <= maximum : exclusiveMaximum === null || min < exclusiveMaximum; +}; + +const isMaximumInsideTemplate = (template: FieldInputTemplate, max: number): boolean => { + const minimum = finiteNumberOrNull(template.minimum); + const exclusiveMinimum = finiteNumberOrNull(template.exclusiveMinimum); + + return minimum !== null ? max >= minimum : exclusiveMinimum === null || max > exclusiveMinimum; +}; + +/** A whole step for an integer field, and a multiple of the node's own step when it has one. */ +const isStepAcceptedByTemplate = (template: FieldInputTemplate, step: number): boolean => + (template.type.name !== 'IntegerField' || Number.isInteger(step)) && + (template.multipleOf === null || template.multipleOf <= 0 || isMultipleOf(step, template.multipleOf)); + +/** + * The template narrowed by the authored number settings: a bound applies only where it tightens the template's own + * end and stays inside its other one (an inverted pair not at all), and a step only when the template has none or + * the step keeps every value a multiple of the template's. Validation, shuffle and the control all read this + * template, so the form never offers a value the node would reject. Ignored settings stay stored for the author to + * correct; `getNodeFieldSettingsIssue` says why. + */ +export const applyNodeFieldFormSettings = ( + template: FieldInputTemplate, + settings: NodeFieldFormSettings | null +): FieldInputTemplate => { + if (!settings || settings.kind !== 'number') { + return template; + } + + const inverted = hasInvertedNodeFieldBounds(settings); + let next = template; + const patch = (fields: Partial) => { + next = next === template ? { ...template, ...fields } : { ...next, ...fields }; + }; + + if (settings.min !== undefined && !inverted && isMinimumInsideTemplate(template, settings.min)) { + if (template.minimum !== null) { + if (settings.min > template.minimum) { + patch({ minimum: settings.min }); + } + } else if (template.exclusiveMinimum === null || settings.min > template.exclusiveMinimum) { + patch({ exclusiveMinimum: null, minimum: settings.min }); + } + } + + if (settings.max !== undefined && !inverted && isMaximumInsideTemplate(template, settings.max)) { + if (template.maximum !== null) { + if (settings.max < template.maximum) { + patch({ maximum: settings.max }); + } + } else if (template.exclusiveMaximum === null || settings.max < template.exclusiveMaximum) { + patch({ exclusiveMaximum: null, maximum: settings.max }); + } + } + + if (settings.step !== undefined && isStepAcceptedByTemplate(template, settings.step)) { + patch({ multipleOf: settings.step }); + } + + return next; +}; + +export interface NodeFieldSliderRange { + max: number; + min: number; + step: number; +} + +// The track step of a float field that accepts any value in range, as in v6. +const FLOAT_SLIDER_STEP = 0.01; + +/** + * The range a slider can show for `template` (already narrowed by `applyNodeFieldFormSettings`), or null when it + * has no track: an end is unbounded, or fewer than two stops would pass validation. The host then keeps the number + * input. + * + * Validation accepts only multiples of the step counted from zero (every integer, for an integer field without + * one), so the track runs between the first and last multiples inside the bounds rather than the bounds + * themselves; a slider stepping by 8 between 1 and 100 offers 8…96, not 1…97. + */ +export const getNodeFieldSliderRange = (template: FieldInputTemplate): NodeFieldSliderRange | null => { + const multipleOf = finiteNumberOrNull(template.multipleOf); + const step = multipleOf !== null && multipleOf > 0 ? multipleOf : template.type.name === 'IntegerField' ? 1 : null; + + if (step !== null) { + const { first, last } = getWorkflowFieldStepIndexBounds(template, step); + + if (first === null || last === null || first >= last) { + return null; + } + + return { max: toWorkflowFieldStepMultiple(last, step), min: toWorkflowFieldStepMultiple(first, step), step }; + } + + const exclusiveMinimum = finiteNumberOrNull(template.exclusiveMinimum); + const exclusiveMaximum = finiteNumberOrNull(template.exclusiveMaximum); + const min = + finiteNumberOrNull(template.minimum) ?? (exclusiveMinimum !== null ? exclusiveMinimum + FLOAT_SLIDER_STEP : null); + const max = + finiteNumberOrNull(template.maximum) ?? (exclusiveMaximum !== null ? exclusiveMaximum - FLOAT_SLIDER_STEP : null); + + if (min === null || max === null || min >= max) { + return null; + } + + return { max, min, step: FLOAT_SLIDER_STEP }; +}; + +export type NodeFieldSettingsIssue = 'inverted' | 'outOfRange' | 'stepIgnored' | 'noValue' | 'noTrack'; + +/** + * Why the form cannot honour authored number settings as written, in the order an author would fix them; null when + * it can. `inverted` and `outOfRange` bounds and a `stepIgnored` step are ignored by `applyNodeFieldFormSettings`; + * with `noValue` no value passes validation, so Invoke stays blocked; with `noTrack` a slider control falls back to + * the number input. + */ +export const getNodeFieldSettingsIssue = ( + template: FieldInputTemplate, + settings: NodeFieldFormSettings | null +): NodeFieldSettingsIssue | null => { + if (settings?.kind !== 'number') { + return null; + } + + if (hasInvertedNodeFieldBounds(settings)) { + return 'inverted'; + } + + if ( + (settings.min !== undefined && !isMinimumInsideTemplate(template, settings.min)) || + (settings.max !== undefined && !isMaximumInsideTemplate(template, settings.max)) + ) { + return 'outOfRange'; + } + + if (settings.step !== undefined && !isStepAcceptedByTemplate(template, settings.step)) { + return 'stepIgnored'; + } + + const effectiveTemplate = applyNodeFieldFormSettings(template, settings); + + if (!admitsWorkflowFieldStepValue(effectiveTemplate)) { + return 'noValue'; + } + + return settings.component !== 'number-input' && getNodeFieldSliderRange(effectiveTemplate) === null + ? 'noTrack' + : null; +}; + +/** + * The template a node field is validated against: the invocation template narrowed by the settings of the form + * element exposing it, or the template itself when the field is not on the form. Invoke readiness and the builder's + * invalid markers read this so they agree with the control. + */ +export const getFormFieldTemplate = ( + form: WorkflowForm, + nodeId: string, + fieldName: string, + template: FieldInputTemplate +): FieldInputTemplate => { + for (const element of Object.values(form.elements)) { + if ( + element.type === 'node-field' && + element.data.fieldIdentifier.nodeId === nodeId && + element.data.fieldIdentifier.fieldName === fieldName + ) { + return applyNodeFieldFormSettings(template, getNodeFieldFormSettings(element.data.settings, template)); + } + } + + return template; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts index 321a7dff1e3..36b0e3c9153 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest'; import type { LayoutGraphEdge, LayoutGraphNode } from './graphLayout'; -import { getLayeredPositions, getNodeDepths, getTopologicalOrder } from './graphLayout'; +import { + getArrangedLayout, + getLayeredPositions, + getNodeDepths, + getSizedLayeredPositions, + getTopologicalOrder, + UNMEASURED_ARRANGE_SIZE, +} from './graphLayout'; // Diamond: a -> b, a -> c, b -> d, c -> d const DIAMOND_NODES: LayoutGraphNode[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]; @@ -62,3 +69,158 @@ describe('getLayeredPositions', () => { expect(positions.d).toEqual({ x: 600, y: 0 }); }); }); + +describe('getSizedLayeredPositions', () => { + it('stacks each layer by node height and places the next layer past the widest node', () => { + const nodes = [ + { height: 100, id: 'a', width: 200 }, + { height: 300, id: 'b', width: 250 }, + { height: 120, id: 'c', width: 180 }, + { height: 80, id: 'd', width: 200 }, + ]; + + expect( + getSizedLayeredPositions(nodes, DIAMOND_EDGES, { layerGap: 50, origin: { x: 10, y: 20 }, rowGap: 10 }) + ).toEqual({ + a: { x: 10, y: 20 }, + b: { x: 260, y: 20 }, + c: { x: 260, y: 330 }, + d: { x: 560, y: 20 }, + }); + }); + + it('orders a layer by the mean row of its parents so edges do not cross', () => { + // r -> p, r -> q, p -> y, q -> x: x and y are listed in the "wrong" order and swap rows to follow their parents. + const nodes = [ + { height: 10, id: 'r', width: 10 }, + { height: 10, id: 'p', width: 10 }, + { height: 10, id: 'q', width: 10 }, + { height: 10, id: 'x', width: 10 }, + { height: 10, id: 'y', width: 10 }, + ]; + const edges = [ + { sourceNodeId: 'r', targetNodeId: 'p' }, + { sourceNodeId: 'r', targetNodeId: 'q' }, + { sourceNodeId: 'p', targetNodeId: 'y' }, + { sourceNodeId: 'q', targetNodeId: 'x' }, + ]; + + const positions = getSizedLayeredPositions(nodes, edges, { rowGap: 0 }); + + expect(positions.y!.y).toBeLessThan(positions.x!.y); + expect(positions.x!.x).toBe(positions.y!.x); + }); + + it('packs unconnected nodes into a near-square grid instead of one column', () => { + const nodes = Array.from({ length: 9 }, (_, index) => ({ height: 50, id: `n${index}`, width: 100 })); + + const positions = getSizedLayeredPositions(nodes, [], { layerGap: 10, rowGap: 10 }); + const xs = new Set(Object.values(positions).map((position) => position.x)); + const ys = new Set(Object.values(positions).map((position) => position.y)); + + expect(xs).toEqual(new Set([0, 110, 220])); + expect(ys).toEqual(new Set([0, 60, 120])); + expect(positions.n0).toEqual({ x: 0, y: 0 }); + expect(positions.n3).toEqual({ x: 0, y: 60 }); + }); + + it('lays out each connected component separately and shares a row while components fit', () => { + const nodes = [ + { height: 50, id: 'a', width: 100 }, + { height: 50, id: 'b', width: 100 }, + { height: 50, id: 'lone', width: 100 }, + ]; + const edges = [{ sourceNodeId: 'a', targetNodeId: 'b' }]; + + expect(getSizedLayeredPositions(nodes, edges, { layerGap: 10, rowGap: 10 })).toEqual({ + a: { x: 0, y: 0 }, + b: { x: 110, y: 0 }, + lone: { x: 220, y: 0 }, + }); + }); + + it('gives a component wider than the row budget its own row', () => { + const nodes = [ + { height: 50, id: 'wide', width: 500 }, + { height: 50, id: 'p', width: 100 }, + { height: 50, id: 'q', width: 100 }, + ]; + + expect(getSizedLayeredPositions(nodes, [], { layerGap: 10, rowGap: 10 })).toEqual({ + wide: { x: 0, y: 0 }, + p: { x: 0, y: 60 }, + q: { x: 110, y: 60 }, + }); + }); + + it('ignores edges that reference nodes outside the arranged set', () => { + const nodes = [ + { height: 50, id: 'a', width: 100 }, + { height: 50, id: 'b', width: 100 }, + ]; + const edges = [ + { sourceNodeId: 'outside', targetNodeId: 'b' }, + { sourceNodeId: 'a', targetNodeId: 'b' }, + ]; + + expect(getSizedLayeredPositions(nodes, edges, { layerGap: 10, rowGap: 10 })).toEqual({ + a: { x: 0, y: 0 }, + b: { x: 110, y: 0 }, + }); + }); +}); + +describe('getArrangedLayout', () => { + const sizes = (entries: Record) => new Map(Object.entries(entries)); + + it('arranges only the selection, anchored at its top-left corner, and leaves other nodes alone', () => { + const nodes = [ + { id: 'a', position: { x: 400, y: 300 } }, + { id: 'b', position: { x: 900, y: 200 } }, + { id: 'outside', position: { x: 0, y: 0 } }, + ]; + const edges = [ + { source: 'a', target: 'b', type: 'default' }, + { source: 'outside', target: 'a', type: 'default' }, + ]; + const { bounds, positions } = getArrangedLayout( + nodes, + edges, + new Set(['a', 'b']), + sizes({ a: { height: 50, width: 100 }, b: { height: 60, width: 120 } }) + ); + + expect(positions).toEqual({ a: { x: 400, y: 200 }, b: { x: 580, y: 200 } }); + expect(bounds).toEqual({ height: 60, width: 300, x: 400, y: 200 }); + }); + + it('ranks by data edges only, so a loop linkage does not fold the body onto its head', () => { + const nodes = [ + { id: 'head', position: { x: 0, y: 0 } }, + { id: 'body', position: { x: 0, y: 0 } }, + ]; + const edges = [ + { source: 'head', target: 'body', type: 'default' }, + { source: 'body', target: 'head', type: 'loop_linkage' }, + ]; + const { positions } = getArrangedLayout( + nodes, + edges, + null, + sizes({ body: { height: 50, width: 100 }, head: { height: 50, width: 100 } }) + ); + + expect(positions.head!.x).toBeLessThan(positions.body!.x); + }); + + it('sizes a node the view has not measured at a typical node size', () => { + const { bounds } = getArrangedLayout( + [{ id: 'a', position: { x: 10, y: 20 } }], + [], + null, + new Map([['a', { height: 0, width: 0 }]]) + ); + + expect(bounds).toEqual({ ...UNMEASURED_ARRANGE_SIZE, x: 10, y: 20 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts index 4915acddcb0..eaf5db7e850 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts @@ -9,6 +9,17 @@ export interface LayoutGraphEdge { targetNodeId: string; } +/** Append to a map of lists, creating the list on first use. */ +const pushTo = (lists: Map, key: K, value: V): void => { + const list = lists.get(key); + + if (list) { + list.push(value); + } else { + lists.set(key, [value]); + } +}; + const LAYER_WIDTH = 300; const ROW_HEIGHT = 100; @@ -18,7 +29,7 @@ export const getNodeDepths = (nodes: LayoutGraphNode[], edges: LayoutGraphEdge[] const incoming = new Map(); for (const edge of edges) { - incoming.set(edge.targetNodeId, [...(incoming.get(edge.targetNodeId) ?? []), edge.sourceNodeId]); + pushTo(incoming, edge.targetNodeId, edge.sourceNodeId); } const resolve = (nodeId: string, seen: Set): number => { @@ -78,3 +89,247 @@ export const getTopologicalOrder = (nodes: LayoutGraphNode[], edges: LayoutGraph .sort((a, b) => a.depth - b.depth || a.index - b.index) .map((entry) => entry.id); }; + +export interface LayoutSizedNode extends LayoutGraphNode { + height: number; + width: number; +} + +export interface SizedLayoutOptions { + /** Horizontal gap between layers; defaults to 80. */ + layerGap?: number; + /** Top-left corner of the arrangement; defaults to the origin. */ + origin?: { x: number; y: number }; + /** Vertical gap between the nodes of one layer; defaults to 40. */ + rowGap?: number; +} + +const DEFAULT_LAYER_GAP = 80; +const DEFAULT_ROW_GAP = 40; + +/** Nodes grouped by undirected connectivity, each group in node-array order; groups ordered by first member. */ +const getConnectedComponents = (nodes: LayoutSizedNode[], edges: LayoutGraphEdge[]): LayoutSizedNode[][] => { + const neighbours = new Map(); + + for (const edge of edges) { + pushTo(neighbours, edge.sourceNodeId, edge.targetNodeId); + pushTo(neighbours, edge.targetNodeId, edge.sourceNodeId); + } + + const componentOf = new Map(); + const components: LayoutSizedNode[][] = []; + + for (const node of nodes) { + if (componentOf.has(node.id)) { + continue; + } + + const index = components.length; + const stack = [node.id]; + + componentOf.set(node.id, index); + + while (stack.length > 0) { + for (const next of neighbours.get(stack.pop()!) ?? []) { + if (!componentOf.has(next)) { + componentOf.set(next, index); + stack.push(next); + } + } + } + + components.push([]); + } + + for (const node of nodes) { + components[componentOf.get(node.id)!]!.push(node); + } + + return components; +}; + +interface ComponentLayout { + height: number; + positions: Record; + width: number; +} + +/** + * Layered layout of one connected graph at the origin: layers are columns as wide as their widest node, nodes stack + * top to bottom within a layer, and a layer's rows follow the mean row of their parents in the layer before (ties + * keep node-array order) so edges tend not to cross. + */ +const layoutComponent = ( + nodes: LayoutSizedNode[], + edges: LayoutGraphEdge[], + layerGap: number, + rowGap: number +): ComponentLayout => { + const depths = getNodeDepths(nodes, edges); + const layers = new Map(); + + for (const node of nodes) { + const depth = depths.get(node.id) ?? 0; + + pushTo(layers, depth, node); + } + + const parents = new Map(); + + for (const edge of edges) { + pushTo(parents, edge.targetNodeId, edge.sourceNodeId); + } + + const positions: Record = {}; + const rowOf = new Map(); + let x = 0; + let height = 0; + + for (const depth of [...layers.keys()].sort((a, b) => a - b)) { + const members = layers.get(depth)!; + const ordered = members + .map((node, index) => { + const parentRows = (parents.get(node.id) ?? []) + .map((parent) => rowOf.get(parent)) + .filter((row): row is number => row !== undefined); + const barycenter = + parentRows.length === 0 ? index : parentRows.reduce((sum, row) => sum + row, 0) / parentRows.length; + + return { barycenter, index, node }; + }) + .sort((a, b) => a.barycenter - b.barycenter || a.index - b.index); + let y = 0; + let width = 0; + + ordered.forEach(({ node }, row) => { + positions[node.id] = { x, y }; + rowOf.set(node.id, row); + height = Math.max(height, y + node.height); + y += node.height + rowGap; + width = Math.max(width, node.width); + }); + + x += width + layerGap; + } + + return { height, positions, width: Math.max(0, x - layerGap) }; +}; + +/** + * Layered layout that respects each node's size. Each connected component is laid out on its own (see + * `layoutComponent`); components then fill rows left to right in node-array order, wrapping at a width that keeps + * the arrangement roughly square, so unconnected nodes form a grid rather than one tall column. A single connected + * graph is simply its layered layout at the origin. + */ +export const getSizedLayeredPositions = ( + nodes: LayoutSizedNode[], + edges: LayoutGraphEdge[], + { layerGap = DEFAULT_LAYER_GAP, origin = { x: 0, y: 0 }, rowGap = DEFAULT_ROW_GAP }: SizedLayoutOptions = {} +): Record => { + const nodeIds = new Set(nodes.map((node) => node.id)); + const knownEdges = edges.filter((edge) => nodeIds.has(edge.sourceNodeId) && nodeIds.has(edge.targetNodeId)); + const components = getConnectedComponents(nodes, knownEdges).map((members) => { + const memberIds = new Set(members.map((node) => node.id)); + + return layoutComponent( + members, + knownEdges.filter((edge) => memberIds.has(edge.sourceNodeId)), + layerGap, + rowGap + ); + }); + const gap = Math.max(layerGap, rowGap); + const perRow = Math.ceil(Math.sqrt(components.length)); + const meanWidth = components.reduce((sum, component) => sum + component.width, 0) / components.length; + const rowBudget = Math.max(...components.map((component) => component.width), perRow * (meanWidth + gap) - gap); + const positions: Record = {}; + let x = 0; + let y = 0; + let rowHeight = 0; + + for (const component of components) { + if (x > 0 && x + component.width > rowBudget) { + x = 0; + y += rowHeight + gap; + rowHeight = 0; + } + + for (const [id, position] of Object.entries(component.positions)) { + positions[id] = { x: origin.x + x + position.x, y: origin.y + y + position.y }; + } + + x += component.width + gap; + rowHeight = Math.max(rowHeight, component.height); + } + + return positions; +}; + +export interface ArrangeNode extends LayoutGraphNode { + position: { x: number; y: number }; +} + +export interface ArrangeEdge { + source: string; + target: string; + type: string; +} + +export interface ArrangedLayout { + /** The arranged nodes' footprint, for the view to fit before the rendered positions catch up. */ + bounds: { height: number; width: number; x: number; y: number }; + positions: Record; +} + +/** The size assumed for a node the view has not measured yet (a large graph renders only what is visible). */ +export const UNMEASURED_ARRANGE_SIZE = { height: 160, width: 288 } as const; + +/** + * The editor's Arrange command: lays out `selectedIds` (every node when null) with `getSizedLayeredPositions`, + * anchored at the arranged nodes' current top-left corner so the result stays where the author was working. Nodes + * outside the selection keep their positions, and edges to them do not rank the arranged ones. Loop linkages are + * ignored too: they point backwards, and ranking by them would fold a loop body onto its own head. + */ +export const getArrangedLayout = ( + nodes: readonly ArrangeNode[], + edges: readonly ArrangeEdge[], + selectedIds: ReadonlySet | null, + measuredSizes: ReadonlyMap +): ArrangedLayout => { + const targets = nodes.filter((node) => !selectedIds || selectedIds.has(node.id)); + const origin = targets.reduce( + (corner, node) => ({ x: Math.min(corner.x, node.position.x), y: Math.min(corner.y, node.position.y) }), + { x: Infinity, y: Infinity } + ); + const sized = targets.map((node) => { + const measured = measuredSizes.get(node.id); + + return { + height: measured?.height || UNMEASURED_ARRANGE_SIZE.height, + id: node.id, + width: measured?.width || UNMEASURED_ARRANGE_SIZE.width, + }; + }); + const layoutEdges = edges + .filter((edge) => edge.type !== 'loop_linkage') + .map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target })); + const positions = getSizedLayeredPositions(sized, layoutEdges, { origin }); + const rect = sized.reduce( + (current, node) => { + const position = positions[node.id]!; + + return { + maxX: Math.max(current.maxX, position.x + node.width), + maxY: Math.max(current.maxY, position.y + node.height), + minX: Math.min(current.minX, position.x), + minY: Math.min(current.minY, position.y), + }; + }, + { maxX: -Infinity, maxY: -Infinity, minX: Infinity, minY: Infinity } + ); + + return { + bounds: { height: rect.maxY - rect.minY, width: rect.maxX - rect.minX, x: rect.minX, y: rect.minY }, + positions, + }; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.browser.test.tsx index 44fd4cf8479..57b9bd3d975 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.browser.test.tsx @@ -71,7 +71,10 @@ beforeEach(() => { toolbarMocks.workflowName = 'Test workflow'; }); -const render = async (nodeOpacity: number) => { +const render = async ( + nodeOpacity: number, + arrangeProps: { arrangeScope?: 'all' | 'selection' | null; onArrangeNodes?: () => void } = {} +) => { applyThemeToRoot('mono'); host = document.createElement('div'); host.className = 'react-flow'; @@ -85,6 +88,7 @@ const render = async (nodeOpacity: number) => { { }); describe('editor toolbar', () => { + it('arranges the selection or the whole graph, and has nothing to arrange without two nodes', async () => { + const onArrangeNodes = vi.fn(); + + await render(1, { arrangeScope: null, onArrangeNodes }); + const disabledButton = host!.querySelector('button[aria-label="Arrange nodes"]')!; + expect(disabledButton.disabled).toBe(true); + + await render(1, { arrangeScope: 'selection', onArrangeNodes }); + await settle(() => host!.querySelector('button[aria-label="Arrange selected nodes"]')!.click()); + expect(onArrangeNodes).toHaveBeenCalledTimes(1); + }); + it('keeps every button on one shared square toolbar box', async () => { // Match custom control sizing; one wider child stretches every button in the column toolbar. await render(1); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx index 964c4edd78d..a2b011fbee4 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx @@ -13,6 +13,7 @@ import { EraserIcon, HandIcon, LassoIcon, + LayoutGridIcon, CircleArrowUpIcon, MaximizeIcon, ZoomInIcon, @@ -55,19 +56,24 @@ const waitForExportFrame = () => }); export const EditorToolbar = ({ + arrangeScope = null, nodeOpacity, tool, updatableNodeCount = 0, + onArrangeNodes, onExportPrepare, onExportComplete, onNodeOpacityChange, onToolChange, onUpdateNodes, }: { + /** What Arrange would lay out: the selection, the whole graph, or nothing (fewer than two nodes). */ + arrangeScope?: 'all' | 'selection' | null; nodeOpacity: number; tool: EditorTool; /** Nodes with a newer same-major template; the update button shows only while there are some. */ updatableNodeCount?: number; + onArrangeNodes?: () => void; onNodeOpacityChange: (opacity: number) => void; onExportPrepare: () => Promise; onExportComplete: () => void; @@ -180,6 +186,16 @@ export const EditorToolbar = ({ label={t('widgets.workflow.toolbar.fitView')} onClick={onFitViewClick} /> + { ); // No id list: the reducer already skips every node that cannot move. const onUpdateNodes = useCallback(() => editGraph({ templates, type: 'updateNodes' }), [editGraph, templates]); + // A lone selected node has nothing to arrange against, so the whole graph is laid out instead. + const arrangeScope: 'all' | 'selection' | null = + selectedNodeIds.length > 1 ? 'selection' : projectGraph.nodes.length > 1 ? 'all' : null; + const onArrangeNodes = useCallback(() => { + if (!flowInstance || arrangeScope === null) { + return; + } + + const { bounds, positions } = getArrangedLayout( + projectGraph.nodes, + projectGraph.edges, + arrangeScope === 'selection' ? new Set(selectedNodeIds) : null, + new Map(flowInstance.getNodes().map((node) => [node.id, node.measured])) + ); + + editGraph({ positions, type: 'setNodePositions' }); + // The flow shows the new positions after the model rebuilds; the bounds are known now, so fit them directly. + void flowInstance.fitBounds(bounds, { duration: reduceMotion ? 0 : 300 }); + }, [arrangeScope, editGraph, flowInstance, projectGraph.edges, projectGraph.nodes, reduceMotion, selectedNodeIds]); if (isPreparing) { return ; @@ -1283,9 +1303,11 @@ const WorkflowFlow = ({ runtime }: { runtime: WorkflowRuntimeApi }) => { /> ); }; + +export interface NumericSliderInputProps { + ariaLabel?: string; + disabled?: boolean; + /** The track; `getNodeFieldSliderRange` derives it from a bounded template. */ + range: { max: number; min: number; step: number }; + onChange: (value: number) => void; + template: NumericInputTemplate; + value: unknown; + /** The combined control pairs the slider with a number input; the slider alone shows its value on the thumb. */ + withNumberInput: boolean; +} + +/** An authored slider for a bounded number field; a value outside the track still moves the thumb to its end. */ +export const NumericSliderInput = ({ + ariaLabel, + disabled, + onChange, + range, + template, + value, + withNumberInput, +}: NumericSliderInputProps) => { + const label = ariaLabel ?? template.title; + const current = typeof value === 'number' && Number.isFinite(value) ? value : range.min; + const sliderAriaLabel = useMemo(() => [label], [label]); + const sliderValue = useMemo(() => [Math.min(range.max, Math.max(range.min, current))], [current, range]); + const onSliderChange = useCallback( + ({ value: values }: { value: number[] }) => { + const next = values[0]; + + if (typeof next === 'number' && Number.isFinite(next)) { + onChange(next); + } + }, + [onChange] + ); + + if (withNumberInput) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx index 8c72444bdd4..42ff9c4c9c1 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx @@ -347,6 +347,89 @@ describe('WorkflowFieldInput textarea', () => { }); }); +describe('WorkflowFieldInput authored form controls', () => { + const renderWithSettings = async ( + template: FieldInputTemplate, + value: unknown, + onChange: (value: unknown) => void, + formSettings: WorkflowFieldInputProps['formSettings'] + ) => { + await act(() => { + root.render( + + + + + + + + ); + }); + }; + + it('restricts a dropdown string field to the authored options and commits the option value', async () => { + const onChange = vi.fn(); + await renderWithSettings(fullTemplate('StringField'), 'slow', onChange, { + component: 'dropdown', + kind: 'string', + options: [ + { label: 'Fast', value: 'fast' }, + { label: '', value: 'slow' }, + ], + }); + + expect(host.querySelector('input:not([type="hidden"]), textarea')).toBeNull(); + const trigger = host.querySelector('[data-scope="select"][data-part="trigger"]')!; + expect(trigger.textContent).toContain('slow'); + + await act(() => userEvent.click(trigger)); + const items = Array.from(document.querySelectorAll('[data-scope="select"][data-part="item"]')); + expect(items.map((item) => item.textContent)).toEqual(['Fast', 'slow']); + + await act(() => userEvent.click(items[0]!)); + expect(onChange).toHaveBeenCalledWith('fast'); + }); + + it('shows a slider for a bounded number field and keeps the input when an end is open', async () => { + const bounded = fullTemplate('IntegerField', { maximum: 20, minimum: 4, multipleOf: 4 }); + await renderWithSettings(bounded, 8, vi.fn(), { component: 'slider', kind: 'number' }); + + const slider = host.querySelector('[role="slider"]')!; + expect(slider.getAttribute('aria-valuemin')).toBe('4'); + expect(slider.getAttribute('aria-valuemax')).toBe('20'); + expect(slider.getAttribute('aria-valuenow')).toBe('8'); + expect(host.querySelector('input[type="number"]')).toBeNull(); + + await renderWithSettings(bounded, 8, vi.fn(), { component: 'number-input-and-slider', kind: 'number' }); + expect(host.querySelector('[role="slider"]')).not.toBeNull(); + expect(host.querySelector('[data-scope="number-input"] input')?.value).toBe('8'); + + await renderWithSettings(fullTemplate('IntegerField', { minimum: 0 }), 8, vi.fn(), { + component: 'slider', + kind: 'number', + }); + expect(host.querySelector('[role="slider"]')).toBeNull(); + expect(host.querySelector('input[type="number"]')?.value).toBe('8'); + }); + + it('ends the slider track on the step multiples the node accepts', async () => { + // Bounds 1–100 with a step of 8 admit 8…96; a track from 1 would offer stops such as 49 that validation rejects. + const misaligned = fullTemplate('IntegerField', { maximum: 100, minimum: 1, multipleOf: 8 }); + await renderWithSettings(misaligned, 16, vi.fn(), { component: 'slider', kind: 'number' }); + + const slider = host.querySelector('[role="slider"]')!; + expect(slider.getAttribute('aria-valuemin')).toBe('8'); + expect(slider.getAttribute('aria-valuemax')).toBe('96'); + + await renderWithSettings(fullTemplate('IntegerField', { maximum: 7, minimum: 1, multipleOf: 8 }), 0, vi.fn(), { + component: 'slider', + kind: 'number', + }); + expect(host.querySelector('[role="slider"]')).toBeNull(); + expect(host.querySelector('input[type="number"]')).not.toBeNull(); + }); +}); + describe('WorkflowFieldInput typed enums', () => { it.each([ { label: 'numeric', options: [256, 512], selected: 512, next: 256 }, diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx index 358599f56de..376836ee3bf 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx @@ -1,7 +1,7 @@ import type { ModelConfig, ModelTaxonomyType } from '@features/models/react'; import type { FieldInputTemplate } from '@features/workflow/contracts'; import type { WorkflowLibraryPage, WorkflowRecordDTO } from '@features/workflow/data/api'; -import type { LoraFieldCollectionEntry } from '@features/workflow/utility'; +import type { LoraFieldCollectionEntry, NodeFieldFormSettings } from '@features/workflow/utility'; import type { SeedInputPatch } from '@platform/ui/SeedInput'; import { @@ -66,10 +66,12 @@ import { finiteNumberOrUndefined, invalidProps, NumericInput, + NumericSliderInput, useFocusedDraft, } from '@features/workflow/ui/fields/NumericInput'; import { useWorkflowProjectSelector, useWorkflowUi } from '@features/workflow/ui/WorkflowUiContext'; import { + getNodeFieldSliderRange, getResolvedWorkflowEdges, isLoraFieldCollectionEntry, isLoraFieldWeightValid, @@ -159,6 +161,11 @@ export interface WorkflowFieldInputProps { /** The instance's seed mode; read only for seed inputs (`isSeedInputField`), which render the mode menu. */ seedMode?: SeedMode; onSeedModeChange?: (seedMode: SeedMode) => void; + /** + * The form element's authored control (dropdown, slider, text area) for a scalar string or number field; the + * template's bounds are expected to be narrowed by `applyNodeFieldFormSettings` already. + */ + formSettings?: NodeFieldFormSettings | null; } // The media well's hover matches DropZone's accent preview; an invalid well keeps its error border and tints instead. @@ -171,7 +178,8 @@ const MEDIA_WELL_INVALID_PROPS = { /** A row of a list names itself by position; a scalar field is named by its title. */ type ScalarInputProps = WorkflowFieldInputProps & { ariaLabel?: string }; -const StringInput = ({ ariaLabel, id, invalid, onChange, template, value }: ScalarInputProps) => { +const StringInput = ({ ariaLabel, formSettings, id, invalid, onChange, template, value }: ScalarInputProps) => { + const { t } = useTranslation(); const [draft, setDraft, clearDraft] = useFocusedDraft(); const text = draft ?? (typeof value === 'string' ? value : ''); const onTextChange = useCallback( @@ -181,8 +189,34 @@ const StringInput = ({ ariaLabel, id, invalid, onChange, template, value }: Scal }, [onChange, setDraft] ); + const stringSettings = formSettings?.kind === 'string' ? formSettings : null; + const component = stringSettings?.component ?? (template.uiComponent === 'textarea' ? 'textarea' : 'input'); + const dropdownOptions = useMemo( + () => + stringSettings?.component === 'dropdown' + ? stringSettings.options.map((option, index) => ({ + label: + option.label || option.value || t('widgets.workflow.formBuilder.unnamedOption', { index: index + 1 }), + value: option.value, + })) + : [], + [stringSettings, t] + ); + + if (component === 'dropdown') { + return ( + + ); + } - if (template.uiComponent === 'textarea') { + if (component === 'textarea') { return ( { + const { formSettings, template } = props; + const component = formSettings?.kind === 'number' ? formSettings.component : 'number-input'; + const range = component === 'number-input' ? null : getNodeFieldSliderRange(template); + + if (!range) { + return ; + } + + return ( + + ); +}; + /** * Plan stepping previews from workflow iterations and template defaults; nokey prevents node shortcuts inside the * row and portalled menu. @@ -1817,9 +1873,9 @@ export const WorkflowFieldInput = (props: WorkflowFieldInputProps) => { ); } - return ; + return ; case 'FloatField': - return ; + return ; case 'BooleanField': return ; case 'EnumField': diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx index 4d970aa5d31..984c5e0828e 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx @@ -34,7 +34,9 @@ import { getWorkflowNodeChromeProps } from '@features/workflow/ui/nodeChrome'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { useWorkflowHostCommands } from '@features/workflow/ui/WorkflowUiContext'; import { + applyNodeFieldFormSettings, getFormChildren, + getNodeFieldFormSettings, getResolvedWorkflowEdges, getWorkflowFieldInvalidReason, isShuffleableField, @@ -81,6 +83,7 @@ import { type AddableFormElementKey, } from './formElementMeta'; import { NodeFieldControl, useNodeFieldBinding } from './NodeFieldControl'; +import { NodeFieldSettingsPopover } from './NodeFieldSettingsPopover'; /** * Commit form moves only at DndContext onDragEnd so reparenting remounts cannot lose completion; all edits use the @@ -433,6 +436,7 @@ const BuilderElementBase = ({ + { @@ -29,9 +30,9 @@ const ViewElement = ({ element, projectGraph }: { element: WorkflowFormElement; ); case 'text': return ( - + {element.data.content} - + ); case 'divider': return ; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.browser.test.tsx new file mode 100644 index 00000000000..ea77071f2c4 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.browser.test.tsx @@ -0,0 +1,46 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { system } from '@theme/system'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LinkifiedText } from './LinkifiedText'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +describe('LinkifiedText', () => { + it('renders URLs as safe new-tab links and leaves the rest of the text as written', async () => { + const content = 'Get the model at https://example.com/models?x=1. not html'; + + await act(() => { + root.render( + + {content} + + ); + }); + + const anchors = host.querySelectorAll('a'); + expect(anchors).toHaveLength(1); + expect(anchors[0]!.getAttribute('href')).toBe('https://example.com/models?x=1'); + expect(anchors[0]!.getAttribute('target')).toBe('_blank'); + expect(anchors[0]!.getAttribute('rel')).toBe('noopener noreferrer'); + expect(anchors[0]!.textContent).toBe('https://example.com/models?x=1'); + expect(host.querySelector('b')).toBeNull(); + expect(host.textContent).toBe(content); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.tsx new file mode 100644 index 00000000000..7cb9e594e34 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.tsx @@ -0,0 +1,32 @@ +import type { TextProps } from '@chakra-ui/react'; + +import { Link, Text } from '@chakra-ui/react'; +import { useMemo } from 'react'; + +import { splitTextIntoLinkSegments } from './linkify'; + +/** Prose with its `http(s)` URLs rendered as links that open in a new tab; the text itself is left as written. */ +export const LinkifiedText = ({ children, ...textProps }: { children: string } & Omit) => { + const segments = useMemo(() => splitTextIntoLinkSegments(children), [children]); + + return ( + + {segments.map((segment, index) => + segment.kind === 'link' ? ( + + {segment.value} + + ) : ( + segment.value + ) + )} + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx index f8e49c5211d..dd28d9b13e3 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx @@ -8,8 +8,10 @@ import { WorkflowFieldInput } from '@features/workflow/ui/fields/WorkflowFieldIn import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { useWorkflowProjectSelector } from '@features/workflow/ui/WorkflowUiContext'; import { + applyNodeFieldFormSettings, cloneWorkflowFieldDefault, getEffectiveWorkflowFieldDescription, + getNodeFieldFormSettings, getRandomWorkflowFieldValue, getResolvedWorkflowEdges, getWorkflowFieldInvalidReason, @@ -24,6 +26,8 @@ import { DicesIcon, RotateCcwIcon } from 'lucide-react'; import { useCallback, useMemo, useState, type ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import { LinkifiedText } from './LinkifiedText'; + /** Resolves a form element's node, field instance, and input template against the document. */ export const useNodeFieldBinding = (element: NodeFieldFormElement, projectGraph: ProjectGraphState) => { const { t } = useTranslation(); @@ -35,6 +39,9 @@ export const useNodeFieldBinding = (element: NodeFieldFormElement, projectGraph: ? (invocationNode.data.dynamicInputTemplates?.[fieldName] ?? templates[invocationNode.data.type]?.inputs[fieldName]) : undefined; const instance = invocationNode?.data.inputs[fieldName]; + const formSettings = template ? getNodeFieldFormSettings(element.data.settings, template) : null; + // The authored bounds narrow the template once here; validation, shuffle and the control all read the result. + const effectiveTemplate = template ? applyNodeFieldFormSettings(template, formSettings) : undefined; const groupId = invocationNode && isWorkflowBatchNodeType(invocationNode.data.type) ? getWorkflowBatchGroupId(invocationNode) @@ -44,7 +51,7 @@ export const useNodeFieldBinding = (element: NodeFieldFormElement, projectGraph: ? `${invocationNode.data.label || templates[invocationNode.data.type]?.title || invocationNode.data.type}${groupSuffix}` : ''; - return { fieldName, instance, invocationNode, nodeContext, nodeId, template }; + return { effectiveTemplate, fieldName, formSettings, instance, invocationNode, nodeContext, nodeId, template }; }; export const NodeFieldControl = ({ @@ -60,10 +67,8 @@ export const NodeFieldControl = ({ const { t } = useTranslation(); const { editGraph } = useProjectGraphCommands(); const projectId = useWorkflowProjectSelector((project) => project.id); - const { fieldName, instance, invocationNode, nodeContext, nodeId, template } = useNodeFieldBinding( - element, - projectGraph - ); + const { effectiveTemplate, fieldName, formSettings, instance, invocationNode, nodeContext, nodeId, template } = + useNodeFieldBinding(element, projectGraph); // The form stays mounted across a workflow switch; a value that arrives later (an upload) must still land in // the workflow this control rendered. const target = useMemo(() => ({ projectId, workflowId: projectGraph.id }), [projectId, projectGraph.id]); @@ -75,8 +80,8 @@ export const NodeFieldControl = ({ ); const label = instance?.label || template?.title || ''; const description = getEffectiveWorkflowFieldDescription(instance, template); - const invalidReason = template - ? getWorkflowFieldInvalidReason({ isConnected, template, value: instance?.value }) + const invalidReason = effectiveTemplate + ? getWorkflowFieldInvalidReason({ isConnected, template: effectiveTemplate, value: instance?.value }) : null; const isInvalid = invalidReason !== null; const canReset = @@ -113,19 +118,21 @@ export const NodeFieldControl = ({ (seedMode: SeedMode) => editGraph({ fieldName, nodeId, seedMode, type: 'setFieldSeedMode' }), [editGraph, fieldName, nodeId] ); + // Read the narrowed template: authored bounds and step can leave no value to shuffle to. const showsShuffle = element.data.showShuffle && !!template && + !!effectiveTemplate && !isConnected && - isShuffleableField(template) && + isShuffleableField(effectiveTemplate) && !isSeedInputField(template); const onShuffleClick = useCallback(() => { - if (template) { - editGraph({ fieldName, nodeId, type: 'setFieldValue', value: getRandomWorkflowFieldValue(template) }); + if (effectiveTemplate) { + editGraph({ fieldName, nodeId, type: 'setFieldValue', value: getRandomWorkflowFieldValue(effectiveTemplate) }); } - }, [editGraph, fieldName, nodeId, template]); + }, [editGraph, effectiveTemplate, fieldName, nodeId]); - if (!invocationNode || !template) { + if (!invocationNode || !template || !effectiveTemplate) { return ( @@ -181,9 +188,9 @@ export const NodeFieldControl = ({ )} {element.data.showDescription && description ? ( - + {description} - + ) : null} {isConnected ? ( @@ -193,11 +200,12 @@ export const NodeFieldControl = ({ ; + +const toOptionalNumber = (raw: string): number | undefined => { + const parsed = Number(raw); + + return raw.trim() !== '' && Number.isFinite(parsed) ? parsed : undefined; +}; + +/** One authored bound: the text stays as typed while focused; a blank or unparseable entry clears the bound. */ +const BoundInput = ({ + describedBy, + label, + onChange, + placeholder, + value, +}: { + describedBy: string; + label: string; + onChange: (value: number | undefined) => void; + placeholder: string; + value: number | undefined; +}) => { + const [draft, setDraft, clearDraft] = useFocusedDraft(); + const onInputChange = useCallback( + (event: ChangeEvent) => { + setDraft(event.currentTarget.value); + onChange(toOptionalNumber(event.currentTarget.value)); + }, + [onChange, setDraft] + ); + + return ( + + + {label} + + + + ); +}; + +const NumberSettingsBody = ({ + onChange, + settings, + template, + templateBounds, +}: { + onChange: (settings: NodeFieldFormSettings) => void; + settings: NodeFieldNumberSettings; + template: FieldInputTemplate; + templateBounds: { max: string; min: string; step: string }; +}) => { + const { t } = useTranslation(); + const controlOptions = useMemo( + () => [ + { label: t('widgets.workflow.formBuilder.controlNumberInput'), value: 'number-input' }, + { label: t('widgets.workflow.formBuilder.controlSlider'), value: 'slider' }, + { label: t('widgets.workflow.formBuilder.controlNumberInputAndSlider'), value: 'number-input-and-slider' }, + ], + [t] + ); + const onComponentChange = useCallback( + (component: string) => onChange({ ...settings, component: component as NodeFieldNumberSettings['component'] }), + [onChange, settings] + ); + const onMinChange = useCallback((min: number | undefined) => onChange({ ...settings, min }), [onChange, settings]); + const onMaxChange = useCallback((max: number | undefined) => onChange({ ...settings, max }), [onChange, settings]); + const onStepChange = useCallback( + (step: number | undefined) => onChange({ ...settings, step: step !== undefined && step > 0 ? step : undefined }), + [onChange, settings] + ); + const issue = getNodeFieldSettingsIssue(template, settings); + const hintId = useId(); + + return ( + + + + + + + + + {t(issue ? SETTINGS_ISSUE_KEYS[issue] : 'widgets.workflow.formBuilder.boundsHint')} + + + ); +}; + +const OptionRow = ({ + index, + onChange, + onRemove, + option, +}: { + index: number; + onChange: (index: number, option: NodeFieldStringOption) => void; + onRemove: (index: number) => void; + option: NodeFieldStringOption; +}) => { + const { t } = useTranslation(); + const onLabelChange = useCallback( + (event: ChangeEvent) => onChange(index, { ...option, label: event.currentTarget.value }), + [index, onChange, option] + ); + const onValueChange = useCallback( + (event: ChangeEvent) => onChange(index, { ...option, value: event.currentTarget.value }), + [index, onChange, option] + ); + const onRemoveClick = useCallback(() => onRemove(index), [index, onRemove]); + const removeLabel = t('widgets.workflow.formBuilder.removeOption', { index: index + 1 }); + + return ( + + + + + + + + + + ); +}; + +const StringSettingsBody = ({ + onChange, + settings, +}: { + onChange: (settings: NodeFieldFormSettings) => void; + settings: NodeFieldStringSettings; +}) => { + const { t } = useTranslation(); + const controlOptions = useMemo( + () => [ + { label: t('widgets.workflow.formBuilder.controlTextInput'), value: 'input' }, + { label: t('widgets.workflow.formBuilder.controlTextarea'), value: 'textarea' }, + { label: t('widgets.workflow.formBuilder.controlDropdown'), value: 'dropdown' }, + ], + [t] + ); + const onComponentChange = useCallback( + (component: string) => onChange({ ...settings, component: component as NodeFieldStringSettings['component'] }), + [onChange, settings] + ); + const onOptionChange = useCallback( + (index: number, option: NodeFieldStringOption) => + onChange({ ...settings, options: settings.options.map((current, i) => (i === index ? option : current)) }), + [onChange, settings] + ); + const onOptionRemove = useCallback( + (index: number) => onChange({ ...settings, options: settings.options.filter((_, i) => i !== index) }), + [onChange, settings] + ); + const onAddOption = useCallback( + () => onChange({ ...settings, options: [...settings.options, { label: '', value: '' }] }), + [onChange, settings] + ); + + return ( + + + {settings.component === 'dropdown' ? ( + + + {t('widgets.workflow.formBuilder.dropdownOptions')} + + {settings.options.length === 0 ? ( + + {t('widgets.workflow.formBuilder.noOptions')} + + ) : null} + {settings.options.map((option, index) => ( + + ))} + + + ) : null} + + ); +}; + +const formatBound = (value: number | null): string => (value === null ? '' : String(value)); + +/** The builder's per-field control settings: which control the form shows and, for numbers, its bounds. */ +export const NodeFieldSettingsPopover = ({ + element, + projectGraph, +}: { + element: NodeFieldFormElement; + projectGraph: ProjectGraphState; +}) => { + const { t } = useTranslation(); + const { editGraph } = useProjectGraphCommands(); + const triggerIds = useTooltipTriggerIds(); + const { formSettings, template } = useNodeFieldBinding(element, projectGraph); + const onSettingsChange = useCallback( + (settings: NodeFieldFormSettings) => { + if (template) { + editGraph({ + elementId: element.id, + settings: serializeNodeFieldFormSettings(settings, template, element.data.showShuffle, element.data.settings), + type: 'setNodeFieldSettings', + }); + } + }, + [editGraph, element.data.settings, element.data.showShuffle, element.id, template] + ); + const templateBounds = useMemo( + () => + template + ? { + max: formatBound(template.maximum ?? template.exclusiveMaximum), + min: formatBound(template.minimum ?? template.exclusiveMinimum), + step: formatBound(template.multipleOf), + } + : { max: '', min: '', step: '' }, + [template] + ); + + if (!template || !hasNodeFieldFormSettings(template) || isSeedInputField(template)) { + return null; + } + + const settings = formSettings ?? getDefaultNodeFieldFormSettings(template); + + if (!settings) { + return null; + } + + const label = t('widgets.workflow.formBuilder.fieldSettings'); + + return ( + + + + + + + + + + + + + + + {t('widgets.workflow.formBuilder.fieldSettingsHint')} + + {settings.kind === 'number' ? ( + + ) : ( + + )} + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx index cc277543caa..0dc4db0f549 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx @@ -6,7 +6,7 @@ import type { ProjectGraphAction } from '@features/workflow/utility'; import { ChakraProvider } from '@chakra-ui/react'; import { useDroppable } from '@dnd-kit/core'; import { WorkflowUiProvider } from '@features/workflow/react'; -import { createProjectGraph, projectGraphReducer } from '@features/workflow/utility'; +import { buildInvocationNode, createProjectGraph, projectGraphReducer } from '@features/workflow/utility'; import { system } from '@theme/system'; import { createInstance } from 'i18next'; import { act, useCallback, useMemo, useState } from 'react'; @@ -730,3 +730,237 @@ describe('Workflow form empty states', () => { expect(overflowingElements()).toEqual([]); }); }); + +describe('Form builder field settings', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement('div'); + host.style.width = '480px'; + document.body.append(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(() => root.unmount()); + host.remove(); + }); + + const renderBuilder = async (fieldName = 'steps') => { + const node = buildInvocationNode(entryTemplate, { x: 0, y: 0 }); + let latest = projectGraphReducer(createProjectGraph('form-settings-test'), { node, type: 'addNode' }); + + latest = projectGraphReducer(latest, { + fieldIdentifier: { fieldName, nodeId: node.id }, + type: 'exposeField', + }); + + const Harness = () => { + const [projectGraph, setProjectGraph] = useState(latest); + const editGraph = useCallback((action: ProjectGraphAction) => { + setProjectGraph((current) => { + latest = projectGraphReducer(current, action); + + return latest; + }); + }, []); + const adapter = useMemo( + () => + ({ + commands: { editGraph, redo: () => undefined, undo: () => undefined }, + project: TEST_PROJECT_PORT, + widgets: { open: () => undefined, patchValues: () => undefined }, + }) as unknown as WorkflowUiAdapter, + [editGraph] + ); + + return ( + + + + + + ); + }; + + await act(() => { + root.render( + + + + ); + }); + + const storedSettings = () => { + const element = Object.values(latest.form.elements).find((candidate) => candidate.type === 'node-field'); + + return element?.type === 'node-field' ? element.data.settings : undefined; + }; + + return { storedSettings }; + }; + + it('keeps both authored bounds while they are inverted and warns instead of dropping one', async () => { + const { storedSettings } = await renderBuilder(); + + await userEvent.click( + host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.fieldSettings')}"]`)! + ); + + const maxInput = document.querySelector( + `input[aria-label="${i18n.t('widgets.workflow.formBuilder.maximum')}"]` + )!; + const minInput = document.querySelector( + `input[aria-label="${i18n.t('widgets.workflow.formBuilder.minimum')}"]` + )!; + + await userEvent.fill(maxInput, '5'); + expect(storedSettings()).toMatchObject({ max: 5, type: 'integer-field-config' }); + + await userEvent.fill(minInput, '10'); + expect(storedSettings()).toMatchObject({ max: 5, min: 10 }); + expect(document.body.textContent).toContain(i18n.t('widgets.workflow.formBuilder.invertedBounds')); + expect(maxInput.value).toBe('5'); + + await userEvent.fill(minInput, '1'); + expect(storedSettings()).toMatchObject({ max: 5, min: 1 }); + expect(document.body.textContent).not.toContain(i18n.t('widgets.workflow.formBuilder.invertedBounds')); + }); + + it('warns that a slider has no track until its bounds admit a step multiple', async () => { + const { storedSettings } = await renderBuilder(); + + await userEvent.click( + host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.fieldSettings')}"]`)! + ); + + const sliderOption = Array.from(document.querySelectorAll('[data-part="item"]')).find( + (item) => item.textContent === i18n.t('widgets.workflow.formBuilder.controlSlider') + )!; + const boundInput = (key: 'maximum' | 'minimum' | 'step') => + document.querySelector(`input[aria-label="${i18n.t(`widgets.workflow.formBuilder.${key}`)}"]`)!; + // Typed into the focused element: a locator for "Step" also matches the exposed "Steps" field by label. + const fillBound = async (key: 'maximum' | 'minimum' | 'step', value: string) => { + boundInput(key).focus(); + await userEvent.keyboard(`{Control>}a{/Control}${value}`); + }; + const hint = i18n.t('widgets.workflow.formBuilder.sliderNeedsBounds'); + + await userEvent.click(sliderOption); + expect(storedSettings()).toMatchObject({ component: 'slider' }); + // The integer template has no bounds of its own. + expect(document.body.textContent).toContain(hint); + + await fillBound('minimum', '1'); + await fillBound('maximum', '8'); + await fillBound('step', '8'); + // 1…8 holds the single multiple 8: a value, but nothing for a track to move between. + expect(document.body.textContent).toContain(hint); + + await fillBound('maximum', '16'); + expect(storedSettings()).toMatchObject({ max: 16, min: 1, step: 8 }); + expect(document.body.textContent).not.toContain(hint); + }); + + it('warns a number input too when its bounds leave no multiple of the step', async () => { + const { storedSettings } = await renderBuilder(); + + await userEvent.click( + host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.fieldSettings')}"]`)! + ); + + const fillBound = async (key: 'maximum' | 'minimum' | 'step', value: string) => { + document + .querySelector(`input[aria-label="${i18n.t(`widgets.workflow.formBuilder.${key}`)}"]`)! + .focus(); + await userEvent.keyboard(`{Control>}a{/Control}${value}`); + }; + const warning = i18n.t('widgets.workflow.formBuilder.noValidValue'); + + await fillBound('minimum', '1'); + await fillBound('maximum', '7'); + await fillBound('step', '8'); + // 1…7 holds no multiple of 8, so every value fails validation whichever control shows the field. + expect(storedSettings()).toMatchObject({ component: 'number-input', max: 7, min: 1, step: 8 }); + expect(document.body.textContent).toContain(warning); + // Announced as it appears, and read with the bound being edited. + const description = document.getElementById(document.activeElement?.getAttribute('aria-describedby') ?? ''); + expect(description?.textContent).toBe(warning); + expect(description?.getAttribute('aria-live')).toBe('polite'); + + await fillBound('maximum', '8'); + expect(document.body.textContent).not.toContain(warning); + }); + + it('offers Shuffle only while the authored bounds and step leave a value to shuffle to', async () => { + await renderBuilder(); + const shuffleButton = () => + host.querySelector(`button[aria-label="${i18n.t('nodes.shuffleField', { label: 'Steps' })}"]`); + const fillBound = async (key: 'maximum' | 'minimum' | 'step', value: string) => { + document + .querySelector(`input[aria-label="${i18n.t(`widgets.workflow.formBuilder.${key}`)}"]`)! + .focus(); + await userEvent.keyboard(`{Control>}a{/Control}${value}`); + }; + + await userEvent.click(host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.showShuffle')}"]`)!); + expect(shuffleButton()).not.toBeNull(); + + await userEvent.click( + host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.fieldSettings')}"]`)! + ); + await fillBound('minimum', '1'); + await fillBound('maximum', '7'); + await fillBound('step', '8'); + // 1…7 holds no multiple of 8: a shuffle could only write a value the node rejects. + expect(shuffleButton()).toBeNull(); + + await fillBound('maximum', '8'); + expect(shuffleButton()).not.toBeNull(); + }); + + it('authors dropdown options and offers exactly those on the form', async () => { + const { storedSettings } = await renderBuilder('prompt'); + const formBuilder = (key: string, index?: number) => i18n.t(`widgets.workflow.formBuilder.${key}`, { index }); + const byLabel = (label: string) => document.querySelector(`[aria-label="${label}"]`)!; + + await userEvent.click(host.querySelector(`[aria-label="${formBuilder('fieldSettings')}"]`)!); + await userEvent.click( + Array.from(document.querySelectorAll('[data-part="item"]')).find( + (item) => item.textContent === formBuilder('controlDropdown') + )! + ); + expect(document.body.textContent).toContain(formBuilder('noOptions')); + + await userEvent.click( + Array.from(document.querySelectorAll('button')).find((button) => button.textContent === formBuilder('addOption'))! + ); + await userEvent.click( + Array.from(document.querySelectorAll('button')).find((button) => button.textContent === formBuilder('addOption'))! + ); + await userEvent.fill(byLabel(formBuilder('optionLabel', 1)), 'Fast'); + await userEvent.fill(byLabel(formBuilder('optionValue', 1)), 'fast'); + await userEvent.fill(byLabel(formBuilder('optionLabel', 2)), 'Slow'); + await userEvent.fill(byLabel(formBuilder('optionValue', 2)), 'slow'); + expect(storedSettings()).toMatchObject({ + component: 'dropdown', + options: [ + { label: 'Fast', value: 'fast' }, + { label: 'Slow', value: 'slow' }, + ], + }); + + await userEvent.click(byLabel(formBuilder('removeOption', 1))); + expect(storedSettings()).toMatchObject({ options: [{ label: 'Slow', value: 'slow' }] }); + expect(byLabel(formBuilder('optionLabel', 1)).value).toBe('Slow'); + + await userEvent.keyboard('{Escape}'); + await act(() => userEvent.click(host.querySelector('[data-scope="select"][data-part="trigger"]')!)); + expect( + Array.from(document.querySelectorAll('[data-scope="select"][data-part="item"]')).map( + (item) => item.textContent + ) + ).toEqual(['Slow']); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.test.ts b/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.test.ts new file mode 100644 index 00000000000..b8474fa7300 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { splitTextIntoLinkSegments } from './linkify'; + +describe('splitTextIntoLinkSegments', () => { + it('keeps plain text as one segment', () => { + expect(splitTextIntoLinkSegments('Pick a model.')).toEqual([{ kind: 'text', value: 'Pick a model.' }]); + expect(splitTextIntoLinkSegments('')).toEqual([]); + }); + + it('separates URLs from the surrounding text without losing a character', () => { + const text = 'Docs: https://invoke.ai/docs and http://example.com/a?b=1&c=2 — mirror.'; + + expect(splitTextIntoLinkSegments(text)).toEqual([ + { kind: 'text', value: 'Docs: ' }, + { kind: 'link', value: 'https://invoke.ai/docs' }, + { kind: 'text', value: ' and ' }, + { kind: 'link', value: 'http://example.com/a?b=1&c=2' }, + { kind: 'text', value: ' — mirror.' }, + ]); + }); + + it('leaves sentence punctuation and unmatched brackets outside the link', () => { + expect(splitTextIntoLinkSegments('(see https://a.b/c).')).toEqual([ + { kind: 'text', value: '(see ' }, + { kind: 'link', value: 'https://a.b/c' }, + { kind: 'text', value: ').' }, + ]); + expect(splitTextIntoLinkSegments('https://en.wikipedia.org/wiki/Foo_(bar), ok')).toEqual([ + { kind: 'link', value: 'https://en.wikipedia.org/wiki/Foo_(bar)' }, + { kind: 'text', value: ', ok' }, + ]); + }); + + it('does not treat other schemes or bare domains as links', () => { + expect(splitTextIntoLinkSegments('ftp://x.y or example.com or javascript:alert(1)')).toEqual([ + { kind: 'text', value: 'ftp://x.y or example.com or javascript:alert(1)' }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.ts b/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.ts new file mode 100644 index 00000000000..03576c81e5d --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.ts @@ -0,0 +1,43 @@ +export type TextSegment = { kind: 'text'; value: string } | { kind: 'link'; value: string }; + +const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/g; +// Sentence punctuation that follows a URL in prose is not part of it. +const TRAILING_PUNCTUATION = /[.,;:!?]+$/; + +const trimUrl = (raw: string): string => { + let url = raw.replace(TRAILING_PUNCTUATION, ''); + + // A closing bracket belongs to the URL only when the URL opened it ("(see https://x/y(z))" vs. "(https://x)"). + while (url.endsWith(')') && (url.match(/\(/g) ?? []).length < (url.match(/\)/g) ?? []).length) { + url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, ''); + } + + return url; +}; + +/** Splits prose into literal text and `http(s)` URLs, in order, so a renderer can turn the URLs into anchors. */ +export const splitTextIntoLinkSegments = (text: string): TextSegment[] => { + const segments: TextSegment[] = []; + let cursor = 0; + + for (const match of text.matchAll(URL_PATTERN)) { + const url = trimUrl(match[0]); + + if (url.length === 0) { + continue; + } + + if (match.index > cursor) { + segments.push({ kind: 'text', value: text.slice(cursor, match.index) }); + } + + segments.push({ kind: 'link', value: url }); + cursor = match.index + url.length; + } + + if (cursor < text.length) { + segments.push({ kind: 'text', value: text.slice(cursor) }); + } + + return segments; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/utility.ts b/invokeai/frontend/webv2/src/features/workflow/utility.ts index e08296a4ff5..6c7ff1c7137 100644 --- a/invokeai/frontend/webv2/src/features/workflow/utility.ts +++ b/invokeai/frontend/webv2/src/features/workflow/utility.ts @@ -3,6 +3,7 @@ export * from './core/connectorHandles'; export * from './core/connectors'; export * from './core/document'; export * from './core/forLoops'; +export * from './core/formFieldSettings'; export * from './core/fields'; export * from './core/graphIndex'; export * from './core/outputFields'; diff --git a/invokeai/frontend/webv2/src/platform/browser/webLocks.browser.test.ts b/invokeai/frontend/webv2/src/platform/browser/webLocks.browser.test.ts index e968a4bf6e5..8105e411cd6 100644 --- a/invokeai/frontend/webv2/src/platform/browser/webLocks.browser.test.ts +++ b/invokeai/frontend/webv2/src/platform/browser/webLocks.browser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { acquireExclusiveLock, isLockHeld } from './webLocks'; +import { acquireExclusiveLock, isLockHeld, waitForSharedLock } from './webLocks'; describe('Web Locks adapter', () => { it('holds one exclusive owner and releases it idempotently', async () => { @@ -40,6 +40,33 @@ describe('Web Locks adapter', () => { } }); + it('waits for a held lock until its holder lets it go, and stops waiting once aborted', async () => { + const name = `invokeai:web-lock-test:${crypto.randomUUID()}`; + const held = await acquireExclusiveLock(name); + const waiting = waitForSharedLock(name, new AbortController().signal); + const abort = new AbortController(); + const abandoned = waitForSharedLock(name, abort.signal); + let isGranted = false; + void waiting.then(() => { + isGranted = true; + }); + + abort.abort(); + await expect(abandoned).resolves.toEqual({ kind: 'unavailable' }); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + expect(isGranted).toBe(false); + if (held.kind === 'acquired') { + await held.release(); + } + const next = await waiting; + expect(next.kind).toBe('acquired'); + if (next.kind === 'acquired') { + await next.release(); + } + }); + it('cannot tell without the Web Locks API', async () => { await expect(isLockHeld('invokeai:web-lock-test:none', {} as LockManager)).resolves.toBeNull(); }); diff --git a/invokeai/frontend/webv2/src/platform/browser/webLocks.ts b/invokeai/frontend/webv2/src/platform/browser/webLocks.ts index f46b08d2405..659ee184fb4 100644 --- a/invokeai/frontend/webv2/src/platform/browser/webLocks.ts +++ b/invokeai/frontend/webv2/src/platform/browser/webLocks.ts @@ -6,7 +6,9 @@ export type BrowserLockResult = const acquireLock = ( name: string, mode: 'exclusive' | 'shared', - lockManager?: LockManager + lockManager?: LockManager, + /** Wait for the lock until it is granted or this aborts, instead of taking it only when free. */ + waitSignal?: AbortSignal ): Promise => { let manager = lockManager; if (!manager) { @@ -30,7 +32,7 @@ const acquireLock = ( try { let request: Promise; request = manager - .request(name, { ifAvailable: true, mode }, async (lock) => { + .request(name, waitSignal ? { mode, signal: waitSignal } : { ifAvailable: true, mode }, async (lock) => { if (!lock) { didResolve = true; resolve({ kind: 'contended' }); @@ -71,6 +73,13 @@ export const acquireExclusiveLock = (name: string, lockManager?: LockManager): P export const acquireSharedLock = (name: string, lockManager?: LockManager): Promise => acquireLock(name, 'shared', lockManager); +/** Wait until the shared lock is granted; `unavailable` without the Web Locks API or once the signal aborts. */ +export const waitForSharedLock = ( + name: string, + signal: AbortSignal, + lockManager?: LockManager +): Promise => acquireLock(name, 'shared', lockManager, signal); + /** * Whether any context of this origin currently holds the lock, read without requesting it, so a holder about to claim * the name is never turned away. `null` when the Web Locks API or its query is unavailable. diff --git a/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.test.ts b/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.test.ts index b1482090ebd..5b32a4d8e9c 100644 --- a/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.test.ts +++ b/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.test.ts @@ -93,6 +93,25 @@ const createWorkflowState = () => { }; describe('active invocation submission', () => { + it('forwards Invoke Front to the submission and omits it otherwise', async () => { + const prepends: (boolean | undefined)[] = []; + const runtime: ActiveInvocationSubmissionRuntime = { + assertCurrent: () => undefined, + capture: () => owner, + flushDrafts: () => undefined, + isCurrent: () => true, + loadPrepareCanvasInvocation: () => Promise.reject(new Error('Only Canvas loads the Canvas chunk')), + submit: (deps) => { + prepends.push(deps.prepend); + }, + }; + + await submitActiveInvocation(createArgs(createGenerateState()), runtime); + await submitActiveInvocation({ ...createArgs(createGenerateState()), prepend: true }, runtime); + + expect(prepends).toEqual([undefined, true]); + }); + it('single-flights a canvas submission before its lazy module loads and until preparation settles', async () => { let resolveModule = (_value: { prepareCanvasInvocation: () => Promise }): void => undefined; const modulePromise = new Promise<{ prepareCanvasInvocation: () => Promise }>((resolve) => { diff --git a/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.ts b/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.ts index 761dfe89ac5..29dcfb15e3b 100644 --- a/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.ts +++ b/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.ts @@ -24,6 +24,8 @@ export interface ActiveInvocationSubmissionArgs { /** Words a control-layer rejection from the locale; its error message is for logs only. */ formatControlLayerError: PrepareCanvasInvocationArgs['formatControlLayerError']; getModels: () => readonly ModelConfig[] | undefined; + /** Invoke Front: the backend runs this batch ahead of the user's other pending work. */ + prepend?: boolean; queries: WorkbenchQueries; } @@ -62,7 +64,14 @@ const runtime: ActiveInvocationSubmissionRuntime = { }; export const submitActiveInvocation = async ( - { commands, destinationOverride, formatControlLayerError, getModels, queries }: ActiveInvocationSubmissionArgs, + { + commands, + destinationOverride, + formatControlLayerError, + getModels, + prepend, + queries, + }: ActiveInvocationSubmissionArgs, activeRuntime: ActiveInvocationSubmissionRuntime = runtime ): Promise => { const owner = activeRuntime.capture(); @@ -98,6 +107,7 @@ export const submitActiveInvocation = async ( models, owner, prepareCanvasInvocation, + ...(prepend ? { prepend } : {}), project: snapshot.activeProject, route, }); diff --git a/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts b/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts index b62aa55c557..bc2b60165bf 100644 --- a/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts +++ b/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts @@ -89,6 +89,7 @@ const GRAPH_EDIT_CONFIDENCE = { setFieldValue: true, setFormElementContent: false, setMetadata: false, + setNodeFieldSettings: false, setNodeFieldShowDescription: false, setNodeFieldShowShuffle: false, setNodeIsIntermediate: false, @@ -96,6 +97,7 @@ const GRAPH_EDIT_CONFIDENCE = { setNodeLabel: false, setNodeNotes: false, setNodePosition: false, + setNodePositions: false, setNodeUseCache: false, setCallSavedWorkflowStatus: false, renameWorkflow: false, diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts index 5d5ee073a4d..2b90fb28731 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts @@ -89,6 +89,7 @@ const implemented = new Set([ 'viewer.recallSeed', 'viewer.remix', 'viewer.swapImages', + 'viewer.toggleMetadata', 'viewer.useSize', 'workflows.addNode', 'workflows.copySelection', @@ -129,7 +130,13 @@ const formatHotkeyTitle = (id: string): string => .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); -const hotkey = (category: HotkeyCategory, id: string, defaultKeys: string[]): HotkeyDefinition => { +/** `title` overrides the one derived from `id` where the command was renamed but its id kept for saved bindings. */ +const hotkey = ( + category: HotkeyCategory, + id: string, + defaultKeys: string[], + title = formatHotkeyTitle(id) +): HotkeyDefinition => { const commandId = `${category}.${id}`; return { @@ -141,7 +148,7 @@ const hotkey = (category: HotkeyCategory, id: string, defaultKeys: string[]): Ho implemented: implemented.has(commandId), preventDefault: true, scope: categoryScopes[category], - title: formatHotkeyTitle(id), + title, }; }; @@ -290,7 +297,7 @@ export const firstPartyHotkeyCatalog: HotkeyDefinition[] = [ hotkey('viewer', 'recallPrompts', ['p']), hotkey('viewer', 'remix', ['r']), hotkey('viewer', 'useSize', ['d']), - hotkey('viewer', 'toggleMetadata', ['i']), + hotkey('viewer', 'toggleMetadata', ['i'], 'Toggle Details'), hotkey('gallery', 'selectAllOnPage', ['mod+a']), hotkey('gallery', 'clearSelection', ['esc']), hotkey('gallery', 'galleryNavUp', ['up']), diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts index 6263e484cf1..e209dfb4bca 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts @@ -114,14 +114,16 @@ export const useRegisterFirstPartyCommands = () => { /** * Submits the active route. `destinationOverride` applies to this run only and * is never written back to the project — "just this once, send it to the - * gallery" must not silently retarget every subsequent invoke. + * gallery" must not silently retarget every subsequent invoke. `prepend` is + * likewise per run: Invoke Front puts this batch ahead of the user's pending work. */ - const submitInvocation = async (destinationOverride?: ResultDestination) => { + const submitInvocation = async (destinationOverride?: ResultDestination, prepend = false) => { await submitActiveInvocation({ commands, destinationOverride, formatControlLayerError: (rejection) => describeControlLayerIssue(t, rejection), getModels: getAvailableModels, + ...(prepend ? { prepend } : {}), queries, }); }; @@ -133,9 +135,9 @@ export const useRegisterFirstPartyCommands = () => { title: 'No image selected', }); - const loadSelectedImageWorkflow = async () => { + const loadSelectedItemWorkflow = async () => { const owner = captureAccountScope(); - const [{ executeLoadImageWorkflow }, { getSelectedGalleryImage }] = await Promise.all([ + const [{ executeLoadMediaWorkflow }, { getSelectedGalleryItem }] = await Promise.all([ import('@workbench/image-actions/executeImageRecall'), import('@workbench/image-actions/selectedImage'), ]); @@ -145,16 +147,16 @@ export const useRegisterFirstPartyCommands = () => { } const activeProject = queries.getSnapshot().activeProject; - const image = getSelectedGalleryImage(activeProject); + const item = getSelectedGalleryItem(activeProject); - if (!image) { + if (!item) { notifyNoImageSelected(); return; } - await executeLoadImageWorkflow({ - image, + await executeLoadMediaWorkflow({ isProjectActive: () => queries.isActiveProject(activeProject.id), + media: { kind: item.kind, name: item.name }, notifications, openWorkflowEditor: () => openWidgetPlacement({ @@ -263,7 +265,11 @@ export const useRegisterFirstPartyCommands = () => { }) ), commandApi.register({ handler: () => submitInvocation(), id: 'app.invoke', title: 'Invoke' }), - commandApi.register({ handler: () => submitInvocation(), id: 'app.invokeFront', title: 'Invoke front' }), + commandApi.register({ + handler: () => submitInvocation(undefined, true), + id: 'app.invokeFront', + title: 'Invoke front', + }), commandApi.register({ handler: () => { const { destination } = queries.getSnapshot().activeProject.invocation; @@ -474,9 +480,9 @@ export const useRegisterFirstPartyCommands = () => { commandApi.register({ handler: () => recallSelectedImage(kind), id, title: id }) ), commandApi.register({ - handler: () => loadSelectedImageWorkflow(), + handler: () => loadSelectedItemWorkflow(), id: LOAD_WORKFLOW_COMMAND_ID, - title: 'Load workflow from image', + title: 'Load workflow from image or video', }), ]; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx index 0f86bea08cd..906e01ae47d 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx @@ -3,6 +3,7 @@ import type { GalleryItemContextMenuTarget } from '@features/gallery/react'; /* oxlint-disable react-perf/jsx-no-new-function-as-prop */ import { ChakraProvider } from '@chakra-ui/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { system } from '@theme/system'; import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -14,6 +15,7 @@ import { ImageContextMenu, type ImageContextMenuTarget } from './ImageContextMen import { EMPTY_IMAGE_RECALL_CAPABILITIES } from './imageRecall'; const NO_BOARDS: [] = []; +const LOAD_WORKFLOW = 'widgets.gallery.itemActions.loadWorkflow.label'; const findGalleryItem = vi.fn(); @@ -67,6 +69,8 @@ const createActions = (deleteItems: ImageActions['deleteItems']): ImageActions = openImageInPreview: vi.fn(), recallImageData: vi.fn(() => Promise.resolve()), getVideoRecallCapabilities: vi.fn(() => Promise.resolve({ all: false, prompts: false, remix: false, seed: false })), + hasVideoWorkflow: vi.fn(() => Promise.resolve(false)), + loadVideoWorkflow: vi.fn(() => Promise.resolve()), recallVideoData: vi.fn(() => Promise.resolve()), selectForCompare: vi.fn(), sendToCanvas: vi.fn(() => Promise.resolve()), @@ -163,9 +167,11 @@ const renderMenu = async (actions: ImageActions, images: GalleryImage[]) => { await interact(() => { root?.render( - - - + + + + + ); }); }; @@ -203,15 +209,17 @@ const renderItemMenu = async ( await interact(() => { root?.render( - - - + + + + + ); }); }; @@ -269,8 +277,8 @@ describe('ImageContextMenu load workflow', () => { ); await renderMenu(actions, [image('made-by-workflow.png')]); - await vi.waitFor(() => expect(getMenuItem('Load Workflow').getAttribute('aria-disabled')).not.toBe('true')); - await interact(() => getMenuItem('Load Workflow').click()); + await vi.waitFor(() => expect(getMenuItem(LOAD_WORKFLOW).getAttribute('aria-disabled')).not.toBe('true')); + await interact(() => getMenuItem(LOAD_WORKFLOW).click()); expect(actions.loadImageWorkflow).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ imageName: 'made-by-workflow.png' }) @@ -282,7 +290,7 @@ describe('ImageContextMenu load workflow', () => { await renderMenu(actions, [image('plain.png')]); await vi.waitFor(() => expect(actions.getImageRecallCapabilities).toHaveBeenCalled()); - expect(getMenuItem('Load Workflow').getAttribute('aria-disabled')).toBe('true'); + expect(getMenuItem(LOAD_WORKFLOW).getAttribute('aria-disabled')).toBe('true'); expect(actions.loadImageWorkflow).not.toHaveBeenCalled(); }); }); @@ -446,6 +454,44 @@ describe('ImageContextMenu video placement', () => { expect(getMenuItem('Extend in Video').getAttribute('aria-disabled')).not.toBe('true'); }); + it('enables Load Workflow once the video is known to embed one and hands the video to the action', async () => { + const video = item('video', 'made-by-workflow.mp4'); + const actions = createActions(vi.fn()); + actions.hasVideoWorkflow = vi.fn(() => Promise.resolve(true)); + await renderVideoMenu(actions, video); + + await vi.waitFor(() => expect(getMenuItem(LOAD_WORKFLOW).getAttribute('aria-disabled')).not.toBe('true')); + await interact(() => getMenuItem(LOAD_WORKFLOW).click()); + + expect(actions.loadVideoWorkflow).toHaveBeenCalledExactlyOnceWith(video); + }); + + it('aborts the video capability reads when the menu closes before they answer', async () => { + const actions = createActions(vi.fn()); + const pending = () => new Promise(() => {}); + actions.getVideoRecallCapabilities = vi.fn(pending); + actions.hasVideoWorkflow = vi.fn(pending); + await renderVideoMenu(actions, item('video', 'slow.mp4')); + await vi.waitFor(() => expect(actions.hasVideoWorkflow).toHaveBeenCalled()); + + await interact(() => root?.unmount()); + + const signals = [ + vi.mocked(actions.getVideoRecallCapabilities).mock.calls[0]?.[1], + vi.mocked(actions.hasVideoWorkflow).mock.calls[0]?.[1], + ]; + expect(signals.map((signal) => signal?.aborted)).toEqual([true, true]); + }); + + it('keeps Load Workflow disabled for a video without one', async () => { + const actions = createActions(vi.fn()); + await renderVideoMenu(actions, item('video', 'plain.mp4')); + + await vi.waitFor(() => expect(actions.hasVideoWorkflow).toHaveBeenCalled()); + expect(getMenuItem(LOAD_WORKFLOW).getAttribute('aria-disabled')).toBe('true'); + expect(actions.loadVideoWorkflow).not.toHaveBeenCalled(); + }); + const openConditioningSubmenu = async (): Promise => { await hoverItem(getMenuItem('Use as Conditioning Clip')); await settleUntil( diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index 0df00546e56..0be50316ed6 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -11,8 +11,11 @@ import { toGalleryItemKey, } from '@features/gallery'; import { getGalleryBoardDestinationGroups, getGalleryBoardLabel } from '@features/gallery/contracts'; +import { galleryKeys } from '@features/gallery/queries'; +import { captureAccountScope } from '@platform/state/accountLifecycle'; import { MenuContent, MenuIconItem } from '@platform/ui'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; +import { useQuery } from '@tanstack/react-query'; import { useFindGalleryItem } from '@workbench/image-actions/useFindGalleryItem'; import { useProjectLibrarySelector } from '@workbench/projects/library'; import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget'; @@ -55,6 +58,17 @@ import type { ImageActions } from './useImageActions'; import { EMPTY_IMAGE_RECALL_CAPABILITIES, type ImageRecallCapabilities } from './imageRecall'; import { EMPTY_VIDEO_RECALL_CAPABILITIES, type VideoRecallCapabilities, type VideoRecallKind } from './videoRecall'; +/** What the menu learns about a video after it opens: metadata recall and whether a workflow is embedded. */ +interface VideoItemCapabilities { + recall: VideoRecallCapabilities; + workflow: boolean; +} + +const EMPTY_VIDEO_ITEM_CAPABILITIES: VideoItemCapabilities = { + recall: EMPTY_VIDEO_RECALL_CAPABILITIES, + workflow: false, +}; + export interface LegacyImageContextMenuTarget { /** Right-clicked image first; more entries switch the menu into bulk mode. */ images: GalleryImage[]; @@ -321,45 +335,33 @@ const SingleItemMenuItems = ({ [actions, itemRef] ); const handleDelete = useCallback(() => onRequestDeletion([itemRef]), [itemRef, onRequestDeletion]); - const [videoRecallCapabilities, setVideoRecallCapabilities] = useState( - EMPTY_VIDEO_RECALL_CAPABILITIES - ); - const [isLoadingVideoRecall, setIsLoadingVideoRecall] = useState(false); - const videoItemName = item.kind === 'video' ? item.name : null; - const getVideoRecallCapabilities = actions.getVideoRecallCapabilities; - - useEffect(() => { - if (!videoItemName || item.kind !== 'video') { - return; - } - - let isCancelled = false; - const videoItem = item; + const owner = captureAccountScope(); + const videoItem = item.kind === 'video' ? item : null; + // The record answers whether a workflow is embedded only when the listing did not say. + const videoCapabilitiesQuery = useQuery({ + enabled: videoItem !== null, + queryKey: galleryKeys.videoMenuCapabilities(owner, videoItem?.name ?? null), + queryFn: async ({ signal }): Promise => { + if (!videoItem) { + return EMPTY_VIDEO_ITEM_CAPABILITIES; + } - setVideoRecallCapabilities(EMPTY_VIDEO_RECALL_CAPABILITIES); - setIsLoadingVideoRecall(true); - getVideoRecallCapabilities(videoItem) - .then((capabilities) => { - if (!isCancelled) { - setVideoRecallCapabilities(capabilities); - } - }) - .catch(() => { - if (!isCancelled) { - setVideoRecallCapabilities(EMPTY_VIDEO_RECALL_CAPABILITIES); - } - }) - .finally(() => { - if (!isCancelled) { - setIsLoadingVideoRecall(false); - } - }); + const [recall, workflow] = await Promise.all([ + actions.getVideoRecallCapabilities(videoItem, signal), + actions.hasVideoWorkflow(videoItem, signal), + ]); - return () => { - isCancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by name; the item identity churns per render - }, [getVideoRecallCapabilities, videoItemName]); + return { recall, workflow }; + }, + }); + const isLoadingVideoRecall = videoCapabilitiesQuery.isPending; + const { recall: videoRecallCapabilities, workflow: hasVideoWorkflow } = + videoCapabilitiesQuery.data ?? EMPTY_VIDEO_ITEM_CAPABILITIES; + const handleLoadVideoWorkflow = useCallback(() => { + if (item.kind === 'video') { + void actions.loadVideoWorkflow(item); + } + }, [actions, item]); const makeVideoRecallHandler = useCallback( (kind: VideoRecallKind) => () => { @@ -380,12 +382,14 @@ const SingleItemMenuItems = ({ }, [actions, item]); const handleUseAsReferenceVideo = useCallback(() => { if (item.kind === 'video') { + // eslint-disable-next-line react/hooks -- `useAsReferenceVideo` is a plain action method; the compiler reads its name as a hook. actions.useAsReferenceVideo(item); } }, [actions, item]); const makeConditioningClipHandler = useCallback( (role: VideoConditioningRole) => () => { if (item.kind === 'video') { + // eslint-disable-next-line react/hooks -- `useAsConditioningClip` is a plain action method; the compiler reads its name as a hook. actions.useAsConditioningClip(item, role); } }, @@ -421,6 +425,13 @@ const SingleItemMenuItems = ({ {item.kind === 'video' ? ( <> + void; recallCapabilities: ImageRecallCapabilities; }) => { + const { t } = useTranslation(); const handleMove = useCallback( (boardId: string) => void actions.moveImagesToBoard([image.imageName], boardId), [actions, image.imageName] @@ -784,7 +796,7 @@ const SingleImageMenuItems = ({ diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts index 3c776c880e4..b319e46ea09 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts @@ -423,6 +423,49 @@ describe('executeImageRecall', () => { ); }); + it('keeps the current references when every recorded reference was deleted', async () => { + const { commands, setSettings } = createCommands(); + const current = { + config: { + image: { original: { image: { height: 64, image_name: 'current.png', width: 64 } } }, + model: null, + type: 'ip_adapter', + }, + id: 'current', + isEnabled: true, + }; + + galleryApi.galleryImages.metadata.mockResolvedValue({ + positive_prompt: 'recalled prompt', + ref_images: [ + { + config: { image: { height: 64, image_name: 'deleted.png', width: 64 }, type: 'qwen_image_reference_image' }, + id: 'deleted', + isEnabled: true, + }, + ], + }); + galleryApi.galleryImages.resolveMany.mockResolvedValue([]); + + await executeImageRecall({ + t, + commands, + generateValues: { modelKey: model.key, referenceImages: [current] }, + image, + kind: 'all', + models: [model], + projectId: 'project-1', + }); + + expect(setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + positivePrompt: 'recalled prompt', + referenceImages: [expect.objectContaining({ id: 'current' })], + }), + 'project-1' + ); + }); + it('does not reuse or commit image metadata from an expired account epoch', async () => { const oldCommands = createCommands(); let resolveOldMetadata: ((value: unknown) => void) | undefined; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts index e08ad5d6403..913d5e46ba6 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts @@ -9,8 +9,9 @@ import type { ModelConfig } from '@features/models'; import type { WorkbenchCommands } from '@workbench/workbenchStore'; import type { TFunction } from 'i18next'; -import { galleryImages } from '@features/gallery'; +import { galleryImages, galleryVideos } from '@features/gallery'; import { + getCompatibleReferenceImages, getDefaultGenerateSettings, getEffectiveReferenceImage, hasArchitectureCapabilities, @@ -55,33 +56,54 @@ const getSkippedConceptMessage = ({ name, reason }: ImageRecallSkip, t: TFunctio } }; +const imageMetadataRequests = new Map }>(); + +registerAccountOwnedResource({ + clear: () => { + imageMetadataRequests.clear(); + }, + name: 'image-recall-metadata', +}); + +const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export interface WorkflowMediaRef { + kind: 'image' | 'video'; + /** The image or video name the backend stores the embedded workflow under. */ + name: string; +} + +const fetchEmbeddedWorkflow = (media: WorkflowMediaRef, signal: AbortSignal) => + media.kind === 'image' ? galleryImages.workflow(media.name, signal) : galleryVideos.workflow(media.name, signal); + /** - * Open the workflow editor before requesting replacement with the image's embedded graph; false means no editor - * can consume it. + * Open the workflow editor before requesting replacement with the workflow embedded in an image or video; false + * from `openWorkflowEditor` means no editor can consume it. */ -export const executeLoadImageWorkflow = async ({ - image, +export const executeLoadMediaWorkflow = async ({ isProjectActive, + media, notifications, openWorkflowEditor, t, }: { - image: GalleryImage; /** Re-checked after the fetch: a store-resident request must not land on another project's editor. */ isProjectActive: () => boolean; + media: WorkflowMediaRef; notifications: Pick; openWorkflowEditor: () => boolean; t: TFunction; }): Promise => { const owner = captureAccountScope(); + const suffix = media.kind === 'video' ? 'Video' : ''; try { - const { workflow } = await galleryImages.workflow(image.imageName, owner.signal); + const { workflow } = await fetchEmbeddedWorkflow(media, owner.signal); assertAccountScopeCurrent(owner); if (!workflow) { - notifications.add({ kind: 'info', title: t('widgets.gallery.itemActions.loadWorkflow.missing') }); + notifications.add({ kind: 'info', title: t(`widgets.gallery.itemActions.loadWorkflow.missing${suffix}`) }); return; } @@ -101,7 +123,7 @@ export const executeLoadImageWorkflow = async ({ requestWorkflowDocumentLoad( raw, - t('widgets.gallery.itemActions.loadWorkflow.loadedLabel', { name: name || image.imageName }) + t(`widgets.gallery.itemActions.loadWorkflow.loadedLabel${suffix}`, { name: name || media.name }) ); } catch (error) { if (!isAccountScopeCurrent(owner)) { @@ -111,22 +133,11 @@ export const executeLoadImageWorkflow = async ({ notifications.add({ kind: 'error', message: toErrorMessage(error), - title: t('widgets.gallery.itemActions.loadWorkflow.failed'), + title: t(`widgets.gallery.itemActions.loadWorkflow.failed${suffix}`), }); } }; -const imageMetadataRequests = new Map }>(); - -registerAccountOwnedResource({ - clear: () => { - imageMetadataRequests.clear(); - }, - name: 'image-recall-metadata', -}); - -const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); - const loadImageMetadata = (imageName: string, owner: AccountScope): Promise => { const cachedRequest = imageMetadataRequests.get(imageName); @@ -287,11 +298,19 @@ export const executeImageRecall = async ({ return false; } - if (result.fields.includes('referenceImages')) { + // An empty recalled list clears the current references; there is nothing to check for it. + if (result.fields.includes('referenceImages') && result.values.referenceImages.length > 0) { result.values.referenceImages = await filterAvailableReferenceImages(result.values.referenceImages, owner.signal); assertAccountScopeCurrent(owner); + // Every recorded reference was deleted: keep the current ones, fitted to the recalled model as an unrecorded list + // would be, rather than clearing them as if the record listed none. if (result.values.referenceImages.length === 0) { + result.values.referenceImages = getCompatibleReferenceImages( + currentGenerateValues.referenceImages, + result.values.model, + models + ); result.fields = result.fields.filter((field) => field !== 'referenceImages'); } } diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts index 18a5426dc58..ecae9a63c1a 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -21,6 +21,7 @@ import { satisfiedSettingsFor, } from '@features/generation/core/graphCoverage.testing'; import { compileGenerateGraph } from '@features/generation/graph'; +import { normalizeReferenceImages } from '@features/generation/settings'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildImageRecallSettings, getImageRecallCapabilities, type ImageRecallKind } from './imageRecall'; @@ -1657,3 +1658,275 @@ describe('remix round trip', () => { ]); }); }); + +describe('legacy records', () => { + const fluxModel: MainModelConfig = { base: 'flux', key: 'flux-model', name: 'FLUX', type: 'main' }; + const recall = ( + metadata: unknown, + overrides: Partial, + supportedModels: MainModelConfig[], + models: ComponentModelConfig[] = supportedModels, + vaeModels: VaeModelConfig[] = [] + ) => + buildImageRecallSettings({ + currentValues: createValues(overrides), + image, + kind: 'all', + metadata, + models, + supportedModels, + vaeModels, + }); + + it('recalls FLUX guidance, which older records carry only under its own key', () => { + const result = recall({ guidance: 7.25, model: { key: fluxModel.key } }, { cfgScale: 1.5, model: fluxModel }, [ + fluxModel, + ]); + + expect(result?.values.cfgScale).toBe(7.25); + expect(result?.fields).toContain('cfg'); + }); + + it('reads cfg_scale for a CFG model even when a guidance key is present', () => { + const result = recall({ cfg_scale: 4, guidance: 7.25, model: { key: sdxlModel.key } }, {}, [sdxlModel]); + + expect(result?.values.cfgScale).toBe(4); + }); + + it('recalls Krea-2 seed variance alongside the rebalance', () => { + const result = recall( + { + krea2_seed_variance_enabled: true, + krea2_seed_variance_randomize_percent: 73, + krea2_seed_variance_strength: 0.42, + model: { key: krea2Model.key }, + }, + { model: krea2Model, modelKey: krea2Model.key }, + [krea2Model] + ); + + expect(result?.values.krea2SeedVarianceEnabled).toBe(true); + expect(result?.values.krea2SeedVarianceStrength).toBe(0.42); + expect(result?.values.krea2SeedVarianceRandomizePercent).toBe(73); + expect(result?.fields).toContain('krea2SeedVariance'); + }); + + it('keeps the current seed variance for a value the enhancer cannot hold', () => { + const result = recall( + { krea2_seed_variance_enabled: true, krea2_seed_variance_strength: 5, model: { key: krea2Model.key } }, + { model: krea2Model, modelKey: krea2Model.key }, + [krea2Model] + ); + + expect(result?.values.krea2SeedVarianceEnabled).toBe(true); + expect(result?.values.krea2SeedVarianceStrength).toBe(createValues().krea2SeedVarianceStrength); + }); + + it('ignores seed variance recorded for a model that is not Krea-2', () => { + const result = recall({ krea2_seed_variance_enabled: true, model: { key: sdxlModel.key } }, {}, [sdxlModel]); + + expect(result?.values.krea2SeedVarianceEnabled).toBe(false); + expect(result?.fields).not.toContain('krea2SeedVariance'); + }); + + describe('explicitly empty references', () => { + const currentReferenceImages = normalizeReferenceImages([ + { + config: { + image: { height: 512, image_name: 'current.png', width: 512 }, + model: { base: 'sdxl', key: 'adapter', name: 'Adapter', type: 'ip_adapter' }, + type: 'ip_adapter', + }, + id: 'current-reference', + isEnabled: true, + }, + ]); + const legacyCanvasReference = { + referenceImages: { + entities: [ + { + id: 'legacy-canvas-ref', + ipAdapter: { + image: { height: 512, image_name: 'legacy.png', width: 512 }, + model: { base: 'sdxl', key: 'adapter', name: 'Adapter', type: 'ip_adapter' }, + type: 'ip_adapter', + }, + isEnabled: true, + }, + ], + }, + }; + + it('clears the current references when the record lists none', () => { + const result = recall( + { model: { key: sdxlModel.key }, positive_prompt: 'no references', ref_images: [] }, + { referenceImages: currentReferenceImages }, + [sdxlModel] + ); + + expect(result?.values.referenceImages).toEqual([]); + expect(result?.fields).toContain('referenceImages'); + }); + + it('does not fall back to pre-v6 canvas references for an empty modern list', () => { + const result = recall( + { canvas_v2_metadata: legacyCanvasReference, model: { key: sdxlModel.key }, ref_images: [] }, + { referenceImages: currentReferenceImages }, + [sdxlModel] + ); + + expect(result?.values.referenceImages).toEqual([]); + }); + + it('offers recall for an image whose only delta is clearing the references', () => { + const capabilities = getImageRecallCapabilities({ + currentValues: createValues({ referenceImages: currentReferenceImages }), + image, + metadata: { ref_images: [] }, + models: [], + supportedModels: [], + vaeModels: [], + }); + + expect(capabilities.all).toBe(true); + }); + + it('leaves the current references when the record says nothing about them', () => { + const result = recall( + { model: { key: sdxlModel.key }, positive_prompt: 'silent' }, + { referenceImages: currentReferenceImages }, + [sdxlModel] + ); + + expect(result?.values.referenceImages).toEqual(currentReferenceImages); + expect(result?.fields).not.toContain('referenceImages'); + }); + }); + + describe('model identity', () => { + const reinstalled: MainModelConfig = { ...sdxlModel, hash: 'blake3:sdxl', key: 'sdxl-reinstalled' }; + const recorded = { base: 'sdxl', hash: 'blake3:sdxl', key: 'sdxl-old-key', name: 'SDXL', type: 'main' }; + + it('resolves a reinstalled main model by its recorded hash', () => { + const result = recall({ model: recorded, positive_prompt: 'old image' }, { model: sd1Model }, [ + sd1Model, + reinstalled, + ]); + + expect(result?.values.model.key).toBe(reinstalled.key); + expect(result?.fields).toContain('model'); + }); + + it('does not let a recorded hash fall back to a same-named model', () => { + const sameName: MainModelConfig = { ...sdxlModel, hash: 'blake3:other', key: 'sdxl-other' }; + const result = recall({ model: recorded, positive_prompt: 'old image' }, { model: sd1Model }, [ + sd1Model, + sameName, + ]); + + expect(result?.values.model.key).toBe(sd1Model.key); + expect(result?.fields).not.toContain('model'); + }); + + it('resolves a pre-identifier record by model name and base', () => { + const result = recall( + { model: { base_model: 'sdxl', model_name: 'SDXL' }, positive_prompt: 'very old image' }, + { model: sd1Model }, + [sd1Model, sdxlModel] + ); + + expect(result?.values.model.key).toBe(sdxlModel.key); + }); + + it('skips a name-and-base record that matches more than one installed model', () => { + const twin: MainModelConfig = { ...sdxlModel, key: 'sdxl-twin' }; + const result = recall( + { model: { base: 'sdxl', name: 'SDXL', type: 'main' }, positive_prompt: 'ambiguous' }, + { model: sd1Model }, + [sd1Model, sdxlModel, twin] + ); + + expect(result?.values.model.key).toBe(sd1Model.key); + expect(result?.fields).not.toContain('model'); + }); + + it('resolves components and the VAE by hash too', () => { + const t5: ComponentModelConfig = { + base: 'any', + hash: 'blake3:t5', + key: 't5-new', + name: 'T5', + type: 't5_encoder', + }; + const vae: VaeModelConfig = { base: 'flux', hash: 'blake3:vae', key: 'vae-new', name: 'FLUX VAE', type: 'vae' }; + const result = recall( + { + model: { key: fluxModel.key }, + t5_encoder: { base: 'any', hash: 'blake3:t5', key: 't5-old', name: 'T5', type: 't5_encoder' }, + vae: { base: 'flux', hash: 'blake3:vae', key: 'vae-old', name: 'FLUX VAE', type: 'vae' }, + }, + { model: fluxModel, modelKey: fluxModel.key }, + [fluxModel], + [fluxModel, t5], + [vae] + ); + + expect(result?.values.t5EncoderModel?.key).toBe(t5.key); + expect(result?.values.vae?.key).toBe(vae.key); + expect(result?.fields).toEqual(expect.arrayContaining(['components', 'vae'])); + }); + + describe('reference-image adapters', () => { + const adapter: ComponentModelConfig = { + base: 'sdxl', + hash: 'blake3:adapter-new', + key: 'adapter-new', + name: 'Adapter', + type: 'ip_adapter', + }; + const recallAdapter = (installed: ComponentModelConfig[]) => + recall( + { + model: { key: sdxlModel.key }, + ref_images: [ + { + config: { + image: { height: 512, image_name: 'reference.png', width: 512 }, + model: { + base: 'sdxl', + hash: 'blake3:adapter-old', + key: 'adapter-old', + name: 'Adapter', + type: 'ip_adapter', + }, + type: 'ip_adapter', + }, + id: 'reference', + isEnabled: true, + }, + ], + }, + {}, + [sdxlModel], + [sdxlModel, ...installed] + )?.values.referenceImages[0]?.config; + + it('resolves a reinstalled adapter by its recorded hash', () => { + const rehashed = { ...adapter, hash: 'blake3:adapter-old', key: 'adapter-rehashed' }; + + expect(recallAdapter([adapter, rehashed])).toMatchObject({ model: { key: rehashed.key } }); + }); + + // Unlike the main model, legacy web falls back to the name when an adapter's hash matches nothing. + it('falls back to name, base and type when the recorded hash matches nothing', () => { + expect(recallAdapter([adapter, { ...adapter, key: 'lora-named-adapter', type: 'lora' }])).toMatchObject({ + model: { key: adapter.key }, + }); + }); + + it('leaves the adapter unset when the name matches more than one installed adapter', () => { + expect(recallAdapter([adapter, { ...adapter, key: 'adapter-twin' }])).toMatchObject({ model: null }); + }); + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts index cdb0cc39075..011e3aeea51 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts @@ -3,8 +3,8 @@ import type { ComponentModelConfig, GenerateLora, GenerateModelConfig, + GenerateReferenceImage, GenerateWidgetValues, - LoraModelConfig, MainModelConfig, VaeModelConfig, } from '@features/generation/contracts'; @@ -35,6 +35,7 @@ import { MAX_FLUX_DYPE_EXPONENT, MAX_FLUX_DYPE_SCALE, MAX_HIDIFFUSION_RATIO, + MAX_KREA2_SEED_VARIANCE_STRENGTH, MAX_TIMESTEP_SHIFT, MAX_ZIMAGE_SEED_VARIANCE_STRENGTH, MIN_HIDIFFUSION_T1_RATIO, @@ -83,6 +84,7 @@ type RecalledField = | 'loras' | 'referenceImages' | 'krea2Rebalance' + | 'krea2SeedVariance' | 'modelTuning' | 'animaInpaintAdapterWeight'; @@ -193,10 +195,18 @@ export const getSteps = (metadata: unknown): number | null => { return steps !== null && steps >= 1 ? steps : null; }; -export const getCfgScale = (metadata: unknown): number | null => { - const cfgScale = getNumber(metadata, 'cfg_scale'); - - return cfgScale !== null && cfgScale >= 1 ? cfgScale : null; +/** + * The value the model's single guidance slider holds. Distilled-guidance families record it as `guidance` and + * their older records carry no `cfg_scale` at all, so for them the dedicated key wins. + */ +export const getCfgScale = (metadata: unknown, model?: GenerateModelConfig): number | null => { + const { guidanceLabel, guidanceMin } = getGenerationUiPolicy(model); + const cfgScale = + guidanceLabel === 'Guidance' + ? (getNumber(metadata, 'guidance') ?? getNumber(metadata, 'cfg_scale')) + : getNumber(metadata, 'cfg_scale'); + + return cfgScale !== null && cfgScale >= guidanceMin ? cfgScale : null; }; export const getCfgRescaleMultiplier = (metadata: unknown): number | null => { @@ -293,20 +303,18 @@ export const getMetadataSize = ( }; }; -const getMetadataModelKey = (metadata: unknown, key: 'model' | 'vae'): string | null => { - const model = getRecord(metadata, key); +/** The installed model a recorded reference names, or null when none (or more than one) does. */ +const resolveMetadataModel = (value: unknown, models: readonly T[]): T | null => { + const ref = toRecordedModelRef(value); + const resolved = ref ? resolveRecordedModel(ref, models) : null; - return typeof model?.key === 'string' ? model.key : null; + return resolved === 'ambiguous' ? null : resolved; }; const getSupportedMetadataModel = ( metadata: unknown, supportedModels: GenerateModelConfig[] -): GenerateModelConfig | null => { - const modelKey = getMetadataModelKey(metadata, 'model'); - - return modelKey ? (supportedModels.find((model) => model.key === modelKey) ?? null) : null; -}; +): GenerateModelConfig | null => resolveMetadataModel(getRecord(metadata, 'model'), supportedModels); const getModelFromMetadata = ( metadata: unknown, @@ -322,13 +330,7 @@ const getModelFromMetadata = ( return null; } - const modelKey = getMetadataModelKey(metadata, key as 'model' | 'vae'); - - if (!modelKey) { - return undefined; - } - - const model = models.find((candidate) => candidate.key === modelKey); + const model = resolveMetadataModel(metadata[key], models); return guard(model) ? model : undefined; }; @@ -358,13 +360,7 @@ const getMetadataVae = ( return null; } - const vaeKey = getMetadataModelKey(metadata, 'vae'); - - if (!vaeKey) { - return undefined; - } - - const vae = vaeModels.find((vae) => vae.key === vaeKey); + const vae = resolveMetadataModel(metadata.vae, vaeModels); return vae && isVaeCompatibleWithGenerateModel(model, vae) ? vae : undefined; }; @@ -399,9 +395,9 @@ export const getMetadataPrompts = (metadata: unknown): { negativePrompt: string; const hasMetadataSize = (metadata: unknown, model: GenerateModelConfig): boolean => Object.keys(getMetadataSize(metadata, model)).length > 0; -const hasGenerationSettings = (metadata: unknown): boolean => +const hasGenerationSettings = (metadata: unknown, model: GenerateModelConfig): boolean => getSteps(metadata) !== null || - getCfgScale(metadata) !== null || + getCfgScale(metadata, model) !== null || getCfgRescaleMultiplier(metadata) !== null || getScheduler(metadata) !== null || getBoolean(metadata, 'seamless_x') !== null || @@ -430,6 +426,33 @@ const getMetadataKrea2Rebalance = ( const hasKrea2Rebalance = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => Object.keys(getMetadataKrea2Rebalance(metadata, model)).length > 0; +/** Bounds match the enhancer's own controls; a value outside them keeps the current setting. */ +const getMetadataKrea2SeedVariance = ( + metadata: unknown, + model: GenerateModelConfig | undefined +): Partial => { + if (model?.base !== 'krea-2') { + return {}; + } + + const enabled = getBoolean(metadata, 'krea2_seed_variance_enabled'); + const strength = getNumber(metadata, 'krea2_seed_variance_strength'); + const randomizePercent = getNumber(metadata, 'krea2_seed_variance_randomize_percent'); + + return { + ...(enabled === null ? {} : { krea2SeedVarianceEnabled: enabled }), + ...(strength !== null && strength >= 0 && strength <= MAX_KREA2_SEED_VARIANCE_STRENGTH + ? { krea2SeedVarianceStrength: strength } + : {}), + ...(randomizePercent !== null && randomizePercent >= 0 && randomizePercent <= 100 + ? { krea2SeedVarianceRandomizePercent: randomizePercent } + : {}), + }; +}; + +const hasKrea2SeedVariance = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => + Object.keys(getMetadataKrea2SeedVariance(metadata, model)).length > 0; + /** * Generation graphs stamp `generation_mode` and record what ran, so on such a record a missing list or switch means * none or the node default. Other records that name a model, such as canvas saves, never record concepts or tuning. @@ -652,12 +675,16 @@ const getComponentPatch = ( const hasComponentModels = (metadata: unknown, models: readonly ComponentModelConfig[]): boolean => Object.keys(getComponentPatch(metadata, models)).length > 0; -/** A recorded `ModelIdentifierField` — `{ key, hash, name, base }` — with every field optional on read. */ +/** + * A recorded `ModelIdentifierField` — `{ key, hash, name, base, type }` — with every field optional on read. Records + * from before identifier fields named the model as `{ model_name, base_model, model_type }`. + */ interface RecordedModelRef { base: string | null; hash: string | null; key: string | null; name: string | null; + type: string | null; } const toRecordedModelRef = (value: unknown): RecordedModelRef | null => { @@ -666,10 +693,11 @@ const toRecordedModelRef = (value: unknown): RecordedModelRef | null => { } const ref = { - base: getString(value, 'base'), + base: getString(value, 'base') ?? getString(value, 'base_model'), hash: getString(value, 'hash'), key: getString(value, 'key'), - name: getString(value, 'name'), + name: getString(value, 'name') ?? getString(value, 'model_name'), + type: getString(value, 'type') ?? getString(value, 'model_type'), }; return ref.key || ref.hash || (ref.name && ref.base) ? ref : null; @@ -677,24 +705,33 @@ const toRecordedModelRef = (value: unknown): RecordedModelRef | null => { /** * Resolve the install-local key first, then a portable identifier: the content hash when one was recorded, otherwise - * name and base. A recorded hash never falls back to a same-named file, and a portable identifier counts only when - * exactly one installed LoRA matches it. + * name and base (and type, when recorded). A portable identifier counts only when exactly one installed model matches + * it. + * + * By default a recorded hash never falls back to a same-named file, so the main model, VAE, components and concepts + * restore only the exact weights that ran. `hashMiss: 'matchName'` lets a hash that matches nothing fall through to + * name and base, as legacy web does for reference-image adapters after a reinstall changes their hash. */ -const resolveRecordedLora = ( +const resolveRecordedModel = ( ref: RecordedModelRef, - loraModels: readonly LoraModelConfig[] -): LoraModelConfig | 'ambiguous' | null => { - const byKey = ref.key ? loraModels.find((model) => model.key === ref.key) : undefined; + models: readonly T[], + hashMiss: 'refuse' | 'matchName' = 'refuse' +): T | 'ambiguous' | null => { + const byKey = ref.key ? models.find((model) => model.key === ref.key) : undefined; if (byKey) { return byKey; } - const matches = ref.hash - ? loraModels.filter((model) => model.hash === ref.hash) - : ref.name && ref.base - ? loraModels.filter((model) => model.name === ref.name && model.base === ref.base) - : []; + const byHash = ref.hash ? models.filter((model) => model.hash === ref.hash) : []; + const matches = + byHash.length > 0 || (ref.hash && hashMiss === 'refuse') + ? byHash + : ref.name && ref.base + ? models.filter( + (model) => model.name === ref.name && model.base === ref.base && (!ref.type || model.type === ref.type) + ) + : []; return matches.length > 1 ? 'ambiguous' : (matches[0] ?? null); }; @@ -756,7 +793,7 @@ const getMetadataConcepts = ( continue; } - const installed = resolveRecordedLora(ref, loraModels); + const installed = resolveRecordedModel(ref, loraModels); if (installed === null || installed === 'ambiguous') { skipped.push({ name: recordedName, reason: installed === 'ambiguous' ? 'ambiguous' : 'unresolved' }); @@ -773,8 +810,16 @@ const getMetadataConcepts = ( return { loras, skipped }; }; -export const getMetadataReferenceImages = (metadata: unknown) => { +/** + * The references the image was generated with, or null when the record says nothing about them. An explicitly + * empty modern list means it used none; only a list with no usable entry falls back to the pre-v6 canvas slot. + */ +export const getMetadataReferenceImages = (metadata: unknown): GenerateReferenceImage[] | null => { if (!isRecord(metadata)) { + return null; + } + + if (Array.isArray(metadata.ref_images) && metadata.ref_images.length === 0) { return []; } @@ -789,18 +834,48 @@ export const getMetadataReferenceImages = (metadata: unknown) => { const referenceImages = getRecord(canvasMetadata, 'referenceImages'); const legacyEntities = referenceImages?.entities; if (!Array.isArray(legacyEntities)) { - return []; + return null; } - return normalizeReferenceImages( + const legacy = normalizeReferenceImages( legacyEntities.map((entry) => isRecord(entry) && isRecord(entry.ipAdapter) ? { config: entry.ipAdapter, id: entry.id, isEnabled: entry.isEnabled } : entry ) ).filter((referenceImage) => referenceImage.config.image !== null); + + return legacy.length > 0 ? legacy : null; }; +/** + * Point each recorded adapter at the installed model it names, or clear it so Generate asks for one: a reinstall gives + * the adapter a new key, and a stale key would pass validation and fail only on the server. + */ +const resolveReferenceImageModels = ( + referenceImages: GenerateReferenceImage[], + models: readonly ComponentModelConfig[] +): GenerateReferenceImage[] => + referenceImages.map((referenceImage) => { + const { config } = referenceImage; + + if ((config.type !== 'ip_adapter' && config.type !== 'flux_redux') || !config.model) { + return referenceImage; + } + + const recordedType = config.model.type; + const ref = toRecordedModelRef(config.model); + const installed = ref + ? resolveRecordedModel( + ref, + models.filter((model) => model.type === recordedType), + 'matchName' + ) + : null; + + return { ...referenceImage, config: { ...config, model: installed === 'ambiguous' ? null : installed } }; + }); + export const withDimensions = ( values: GenerateWidgetValues, size: Partial> @@ -853,11 +928,14 @@ export const getImageRecallCapabilities = ({ const hasSeed = getSeed(metadata) !== null; const hasPrompts = hasPrompt(metadata); const hasSize = hasMetadataSize(metadata, currentValues.model); - const hasSettings = hasGenerationSettings(metadata); + const hasSettings = hasGenerationSettings(metadata, clipSkipModel); const hasComponents = hasComponentModels(metadata, models); - const hasReferenceImages = getMetadataReferenceImages(metadata).length > 0; + const recordedReferenceImages = getMetadataReferenceImages(metadata); + const hasReferenceImages = + recordedReferenceImages !== null && + (recordedReferenceImages.length > 0 || currentValues.referenceImages.length > 0); const hasModel = supportedMetadataModel !== null; - const hasRebalance = hasKrea2Rebalance(metadata, clipSkipModel); + const hasRebalance = hasKrea2Rebalance(metadata, clipSkipModel) || hasKrea2SeedVariance(metadata, clipSkipModel); const hasTuning = hasModelTuning(metadata, clipSkipModel); const hasAdapterWeight = hasAnimaInpaintAdapterWeight(metadata, clipSkipModel); const hasConcepts = @@ -960,6 +1038,13 @@ export const buildImageRecallSettings = ({ fields.push('krea2Rebalance'); } + const seedVariancePatch = getMetadataKrea2SeedVariance(metadata, values.model); + + if (Object.keys(seedVariancePatch).length > 0) { + values = { ...values, ...seedVariancePatch }; + fields.push('krea2SeedVariance'); + } + const tuningPatch = getMetadataModelTuning(metadata, values.model); // A same-family record always restores its switches, so only count the field when one actually changes. @@ -1007,9 +1092,15 @@ export const buildImageRecallSettings = ({ // Recalled reference images must fit the effective model — when the // metadata model was not recalled (e.g. uninstalled), `values.model` is // still the current one and incompatible configs are re-targeted or dropped. - const referenceImages = getCompatibleReferenceImages(getMetadataReferenceImages(metadata), values.model, models); + const recordedReferenceImages = getMetadataReferenceImages(metadata); + const referenceImages = getCompatibleReferenceImages( + resolveReferenceImageModels(recordedReferenceImages ?? [], models), + values.model, + models + ); - if (referenceImages.length > 0) { + // A record of no references clears the current ones; a record whose references do not fit leaves them. + if (referenceImages.length > 0 || (recordedReferenceImages?.length === 0 && values.referenceImages.length > 0)) { values = { ...values, referenceImages }; fields.push('referenceImages'); } @@ -1051,7 +1142,7 @@ export const buildImageRecallSettings = ({ if (kind === 'all' || kind === 'remix') { const steps = getSteps(metadata); - const cfgScale = getCfgScale(metadata); + const cfgScale = getCfgScale(metadata, values.model); const cfgRescaleMultiplier = getCfgRescaleMultiplier(metadata); const scheduler = getScheduler(metadata); const seamlessXAxis = getBoolean(metadata, 'seamless_x'); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.test.ts new file mode 100644 index 00000000000..f7e2f949022 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.test.ts @@ -0,0 +1,129 @@ +import type { GalleryItemRef } from '@features/gallery/contracts'; +import type { CanvasNodeContract } from '@workbench/canvas-engine/contracts'; + +import { describe, expect, it } from 'vitest'; + +import { collectMediaUsage } from './mediaUsage'; + +const widget = (typeId: string, values: Record) => ({ + createdAt: '2026-01-01T00:00:00.000Z', + id: typeId, + state: { id: typeId, label: typeId, values, version: 1 as const }, + typeId, +}); + +const rasterLayer = (id: string, imageName: string) => + ({ + id, + isEnabled: true, + isLocked: false, + name: id, + source: { image: { height: 64, imageName, width: 64 }, type: 'image' }, + type: 'raster', + }) as unknown as CanvasNodeContract; + +const project = ({ + stacks = {}, + widgets = {}, + workflowNodes = [], +}: { + stacks?: Partial>; + widgets?: Record>; + workflowNodes?: unknown[]; +} = {}) => + ({ + canvas: { + document: { + stacks: { control: [], inpaint_mask: [], raster: [], regional_guidance: [], ...stacks }, + }, + // Snapshots are history: a deleted name here must not count as in use. + snapshots: [{ document: { stacks: { raster: [rasterLayer('old', 'snapshot-only.png')] } } }], + }, + widgetInstances: Object.fromEntries( + Object.entries(widgets).map(([typeId, values]) => [typeId, widget(typeId, values)]) + ), + workflows: { + activeWorkflowId: 'wf', + entries: [{ document: { edges: [], name: 'Upscale pass', nodes: workflowNodes } }], + }, + }) as unknown as Parameters[0]; + +const image = (name: string): GalleryItemRef => ({ kind: 'image', name }); +const video = (name: string): GalleryItemRef => ({ kind: 'video', name }); + +describe('collectMediaUsage', () => { + it('reports nothing for an unused selection or an empty one', () => { + const doc = project({ stacks: { raster: [rasterLayer('a', 'kept.png')] } }); + + expect(collectMediaUsage(doc, [image('other.png')])).toEqual([]); + expect(collectMediaUsage(doc, [])).toEqual([]); + }); + + it('names each surface that uses a selected image, in panel order, with only the selected names', () => { + const doc = project({ + stacks: { + control: [rasterLayer('c', 'control.png')], + inpaint_mask: [rasterLayer('m', 'mask.png')], + raster: [rasterLayer('r1', 'raster.png'), rasterLayer('r2', 'raster.png'), rasterLayer('r3', 'other.png')], + regional_guidance: [ + { + id: 'rg', + referenceImages: [{ config: { image: { image_name: 'regional-ref.png' } } }], + type: 'regional_guidance', + } as unknown as CanvasNodeContract, + ], + }, + widgets: { + generate: { referenceImages: [{ config: { image: { image_name: 'ref.png' } } }] }, + upscale: { inputImage: { height: 1, image_name: 'upscale.png', width: 1 } }, + video: { firstFrameImage: { height: 1, image_name: 'frame.png', width: 1 } }, + }, + workflowNodes: [{ data: { inputs: { image: { value: { image_name: 'wf.png' } } } } }], + }); + const selected = [ + image('wf.png'), + image('frame.png'), + image('upscale.png'), + image('regional-ref.png'), + image('mask.png'), + image('control.png'), + image('raster.png'), + image('ref.png'), + image('unused.png'), + ]; + + expect(collectMediaUsage(doc, selected)).toEqual([ + { names: ['ref.png'], surface: 'generateReferenceImages' }, + { names: ['raster.png'], surface: 'canvasRaster' }, + { names: ['control.png'], surface: 'canvasControl' }, + { names: ['regional-ref.png'], surface: 'canvasRegionalGuidance' }, + { names: ['mask.png'], surface: 'canvasInpaintMask' }, + { names: ['upscale.png'], surface: 'upscale' }, + { names: ['frame.png'], surface: 'video' }, + { label: 'Upscale pass', names: ['wf.png'], surface: 'workflow' }, + ]); + }); + + it('matches videos by video keys only, so an image and a video sharing a name do not cross over', () => { + const doc = project({ + widgets: { + video: { + references: [{ clip: { video_name: 'same-name' }, kind: 'video' }], + sourceVideo: { video_name: 'clip.mp4' }, + }, + }, + workflowNodes: [{ data: { inputs: { image: { value: { image_name: 'same-name' } } } } }], + }); + + expect(collectMediaUsage(doc, [video('clip.mp4'), video('same-name'), image('same-name')])).toEqual([ + { names: ['clip.mp4', 'same-name'], surface: 'video' }, + { label: 'Upscale pass', names: ['same-name'], surface: 'workflow' }, + ]); + }); + + it('ignores canvas snapshots and media the gallery merely selects', () => { + const doc = project({ widgets: { gallery: { selectedImageName: 'snapshot-only.png' } } }); + + expect(collectMediaUsage(doc, [image('snapshot-only.png')])).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.ts b/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.ts new file mode 100644 index 00000000000..d4cc72ba904 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.ts @@ -0,0 +1,71 @@ +/** + * Where a project still uses gallery media the user is about to delete. Deleting such media removes the backing + * file under authored work, so the deletion flow must warn even when ordinary confirmations are turned off. + */ + +import type { GalleryItemRef } from '@features/gallery/contracts'; +import type { Project } from '@workbench/projectContracts'; + +import { collectMediaNames, MAX_HELD_NAME_LENGTH, type MediaNameRefs } from '@workbench/mediaReferences'; +import { getProjectWidgetValues } from '@workbench/widgetState'; + +export type MediaUsageSurface = + | 'generateReferenceImages' + | 'canvasRaster' + | 'canvasControl' + | 'canvasRegionalGuidance' + | 'canvasInpaintMask' + | 'upscale' + | 'video' + | 'workflow'; + +export interface MediaUsage { + surface: MediaUsageSurface; + /** The workflow's name, for the `workflow` surface. */ + label?: string; + /** Selected names this surface uses, in selection order. */ + names: string[]; +} + +type UsageProject = Pick; + +const scan = (roots: readonly unknown[]): MediaNameRefs => collectMediaNames(roots, () => false, MAX_HELD_NAME_LENGTH); + +const selectedNamesIn = (refs: MediaNameRefs, selected: readonly GalleryItemRef[]): string[] => + selected.filter((ref) => (ref.kind === 'image' ? refs.images : refs.videos).has(ref.name)).map((ref) => ref.name); + +/** + * The project's live surfaces that use any of `selected`, in panel order. History (undo, snapshots, queue) does + * not count: deleting what only history could restore is a loss, not a hole in current work. + */ +export const collectMediaUsage = (project: UsageProject, selected: readonly GalleryItemRef[]): MediaUsage[] => { + if (selected.length === 0) { + return []; + } + + const usage: MediaUsage[] = []; + const add = (surface: MediaUsageSurface, roots: readonly unknown[], label?: string): void => { + const names = selectedNamesIn(scan(roots), selected); + + if (names.length > 0) { + usage.push(label === undefined ? { names, surface } : { label, names, surface }); + } + }; + + add('generateReferenceImages', [getProjectWidgetValues(project, 'generate').referenceImages]); + + const { stacks } = project.canvas.document; + add('canvasRaster', [stacks.raster]); + add('canvasControl', [stacks.control]); + add('canvasRegionalGuidance', [stacks.regional_guidance]); + add('canvasInpaintMask', [stacks.inpaint_mask]); + + add('upscale', [getProjectWidgetValues(project, 'upscale').inputImage]); + add('video', [getProjectWidgetValues(project, 'video')]); + + for (const entry of project.workflows.entries) { + add('workflow', [entry.document.nodes], entry.document.name); + } + + return usage; +}; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/selectedImage.ts b/invokeai/frontend/webv2/src/workbench/image-actions/selectedImage.ts index f833c704d66..8c78885654d 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/selectedImage.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/selectedImage.ts @@ -1,10 +1,14 @@ -import type { GalleryImage } from '@features/gallery'; +import type { GalleryImage, GalleryItem } from '@features/gallery'; import type { Project } from '@workbench/projectContracts'; -import { getSelectedGalleryImageFromValues } from '@features/gallery/contracts'; +import { getSelectedGalleryImageFromValues, getSelectedGalleryItemFromValues } from '@features/gallery/contracts'; import { getProjectWidgetValues } from '@workbench/widgetState'; export { getSelectedGalleryImageFromValues } from '@features/gallery/contracts'; export const getSelectedGalleryImage = (project: Project): GalleryImage | null => getSelectedGalleryImageFromValues(getProjectWidgetValues(project, 'gallery')); + +/** The selected image or video, for commands that apply to either. */ +export const getSelectedGalleryItem = (project: Project): GalleryItem | null => + getSelectedGalleryItemFromValues(getProjectWidgetValues(project, 'gallery')); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx index 8c4e1d7772d..a3ce718f20c 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx @@ -35,6 +35,11 @@ vi.mock('react-i18next', () => ({ 'widgets.gallery.deleteItemsConfirmBody': count === 1 ? 'Permanently delete the item.' : 'Permanently delete these items.', 'widgets.gallery.deleteItemsConfirmTitle': count === 1 ? 'Delete item?' : `Delete ${count} items?`, + 'widgets.gallery.deleteInUse.heading': 'Still used in this project:', + 'widgets.gallery.deleteInUse.surface.canvasRaster': 'Canvas raster layers', + 'widgets.gallery.deleteInUse.surface.workflow': `Workflow “${String(values?.name)}”`, + 'widgets.gallery.deleteInUse.usedCount': count === 1 ? '1 selected item' : `${count} selected items`, + 'widgets.gallery.deleteInUse.warning': 'The affected work keeps pointing at media that will no longer exist.', }; return messages[key] ?? key; @@ -141,6 +146,69 @@ describe('useDeletionConfirmation', () => { expect(getWorkbenchPreferences().confirmImageDeletion).toBe(true); }); + it('lists where the project still uses the items, with the count each surface loses', async () => { + const executeDeletion = vi.fn(async () => {}); + const consoleError = vi.spyOn(console, 'error'); + + await act(() => { + void confirmationRef.current!.requestDeletionConfirmation(mixedRefs, executeDeletion, undefined, [ + { names: ['image.png'], surface: 'canvasRaster' }, + { label: 'Upscale pass', names: ['image.png', 'video.mp4'], surface: 'workflow' }, + // Workflow names are not unique. + { label: 'Upscale pass', names: ['video.mp4'], surface: 'workflow' }, + ]); + }); + + const dialog = openDialog(); + const group = dialog?.querySelector('[role="group"]'); + expect(group?.getAttribute('aria-labelledby')).toBeTruthy(); + expect(dialog?.querySelector(`#${group?.getAttribute('aria-labelledby')}`)?.textContent).toBe( + 'Still used in this project:' + ); + const rows = Array.from(group?.querySelectorAll('li') ?? []).map((row) => row.textContent); + expect(rows).toEqual([ + 'Canvas raster layers · 1 selected item', + 'Workflow “Upscale pass” · 2 selected items', + 'Workflow “Upscale pass” · 1 selected item', + ]); + expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining('same key'), expect.anything()); + expect(dialog?.textContent).toContain('The affected work keeps pointing at media that will no longer exist.'); + // With routine confirmations on, the opt-out still means something for later deletions. + expect(skipCheckbox()).not.toBeNull(); + + await clickButton('Delete'); + expect(executeDeletion).toHaveBeenCalledOnce(); + consoleError.mockRestore(); + }); + + it('offers no opt-out when routine confirmations are already off and the media is in use', async () => { + await patchWorkbenchPreferences({ confirmImageDeletion: false }); + + await act(() => { + void confirmationRef.current!.requestDeletionConfirmation( + imageRefs, + vi.fn(async () => {}), + undefined, + [{ names: ['image.png'], surface: 'canvasRaster' }] + ); + }); + + expect(openDialog()?.textContent).toContain('Still used in this project:'); + expect(skipCheckbox()).toBeNull(); + expect(openDialog()?.textContent).not.toContain('You can turn this confirmation back on in Settings.'); + }); + + it('shows no usage section for a deletion nothing depends on', async () => { + await act(() => { + void confirmationRef.current!.requestDeletionConfirmation( + imageRefs, + vi.fn(async () => {}) + ); + }); + + expect(openDialog()?.querySelector('[role="group"]')).toBeNull(); + }); + it('cancels without executing deletion', async () => { const executeDeletion = vi.fn(async () => {}); let request!: Promise; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx index a7cf06aaea1..d6ae0a90fbf 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx @@ -1,26 +1,33 @@ import type { GalleryItemRef } from '@features/gallery/contracts'; import type { ReactNode } from 'react'; -import { Checkbox, Stack, Text } from '@chakra-ui/react'; +import { Checkbox, List, Stack, Text } from '@chakra-ui/react'; import { useMountEffect } from '@platform/react/useMountEffect'; import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; -import { patchWorkbenchPreferences } from '@workbench/settings/store'; +import { patchWorkbenchPreferences, useWorkbenchPreferenceSelector } from '@workbench/settings/store'; import { useCallback, useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { MediaUsage } from './mediaUsage'; + export type RequestDeletionConfirmation = ( itemRefs: readonly GalleryItemRef[], executeDeletion: () => Promise, /** Where focus goes when the dialog closes; without it, back to the control that opened it. */ - returnFocus?: () => HTMLElement | null + returnFocus?: () => HTMLElement | null, + /** Project surfaces that still use some of the items; listed so the user sees what the deletion breaks. */ + usage?: readonly MediaUsage[] ) => Promise; interface PendingDeletion { executeDeletion: () => Promise; itemRefs: readonly GalleryItemRef[]; resolve: () => void; + usage: readonly MediaUsage[]; } +const NO_USAGE: readonly MediaUsage[] = []; + export const useDeletionConfirmation = (): { dialog: ReactNode; requestDeletionConfirmation: RequestDeletionConfirmation; @@ -33,7 +40,11 @@ export const useDeletionConfirmation = (): { const deletionInFlightRef = useRef(false); // Read only when deletion is confirmed, so ticking it and then cancelling changes nothing. const [skipFutureConfirmations, setSkipFutureConfirmations] = useState(false); + // With routine confirmations already off, the dialog only appears because the media is in use, which the opt-out + // does not cover. + const canSkipFutureConfirmations = useWorkbenchPreferenceSelector((preferences) => preferences.confirmImageDeletion); const skipHintId = useId(); + const usageHeadingId = useId(); const settlePendingDeletion = useCallback(() => { const pending = pendingDeletionRef.current; @@ -49,13 +60,13 @@ export const useDeletionConfirmation = (): { }, []); const requestDeletionConfirmation = useCallback( - (itemRefs, executeDeletion, getReturnFocus) => { + (itemRefs, executeDeletion, getReturnFocus, usage = NO_USAGE) => { if (pendingDeletionRef.current) { return Promise.resolve(); } return new Promise((resolve) => { - const pending = { executeDeletion, itemRefs: [...itemRefs], resolve }; + const pending = { executeDeletion, itemRefs: [...itemRefs], resolve, usage }; pendingDeletionRef.current = pending; setSkipFutureConfirmations(false); @@ -101,6 +112,7 @@ export const useDeletionConfirmation = (): { const isImageOnly = pendingDeletion?.itemRefs.every((item) => item.kind === 'image') ?? false; const titleKey = isImageOnly ? 'widgets.gallery.deleteImagesConfirmTitle' : 'widgets.gallery.deleteItemsConfirmTitle'; const bodyKey = isImageOnly ? 'widgets.gallery.deleteImagesConfirmBody' : 'widgets.gallery.deleteItemsConfirmBody'; + const usage = pendingDeletion?.usage ?? NO_USAGE; return { dialog: ( @@ -108,18 +120,42 @@ export const useDeletionConfirmation = (): { body={ <> {t(bodyKey, { count: itemCount })} - - - - - - - {t('widgets.gallery.deleteConfirmSkip')} - - - {t('widgets.gallery.deleteConfirmSkipHint')} - - + {usage.length > 0 ? ( + + + {t('widgets.gallery.deleteInUse.heading')} + + + {usage.map((entry, index) => ( + // Captured when the dialog opens and never reordered; labels such as workflow names can repeat. + + {t(`widgets.gallery.deleteInUse.surface.${entry.surface}`, { name: entry.label })} + + {' · '} + {t('widgets.gallery.deleteInUse.usedCount', { count: entry.names.length })} + + + ))} + + + {t('widgets.gallery.deleteInUse.warning')} + + + ) : null} + {canSkipFutureConfirmations ? ( + + + + + + + {t('widgets.gallery.deleteConfirmSkip')} + + + {t('widgets.gallery.deleteConfirmSkipHint')} + + + ) : null} } confirmLabel={t('widgets.gallery.deleteConfirmLabel')} diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx index 3a608f51d57..53351c96bed 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx @@ -48,6 +48,9 @@ const mocks = vi.hoisted(() => ({ ), requestWorkflowDocumentLoad: vi.fn(), videoMetadata: vi.fn((..._args: unknown[]) => Promise.resolve(null)), + videoWorkflow: vi.fn((..._args: unknown[]): Promise<{ graph: string | null; workflow: string | null }> => + Promise.resolve({ graph: null, workflow: null }) + ), invalidateGallery: vi.fn(), invalidateGalleryItems: vi.fn(), itemDelete: vi.fn(), @@ -88,7 +91,10 @@ vi.mock('@features/gallery', () => ({ setStarred: (...args: unknown[]) => mocks.setStarred(...args), }, galleryTransfers: { downloadArchive: (...args: unknown[]) => mocks.downloadArchive(...args) }, - galleryVideos: { metadata: (...args: unknown[]) => mocks.videoMetadata(...args) }, + galleryVideos: { + metadata: (...args: unknown[]) => mocks.videoMetadata(...args), + workflow: (...args: unknown[]) => mocks.videoWorkflow(...args), + }, legacyGeneratedImageToGalleryItem: (image: { boardId?: string; imageName: string; starred?: boolean }) => ({ boardId: image.boardId ?? 'none', kind: 'image' as const, @@ -199,7 +205,7 @@ vi.mock('react-i18next', () => ({ if (key === 'widgets.gallery.itemActions.move.success') { return `Moved to ${String(values?.board)}`; } - if (key === 'widgets.gallery.itemActions.loadWorkflow.loadedLabel') { + if (key.startsWith('widgets.gallery.itemActions.loadWorkflow.loadedLabel')) { return `${key}:${String(values?.name)}`; } @@ -228,7 +234,9 @@ const makeMockProject = ( galleryValues: Record = {}, upscaleValues: Record = {} ) => ({ + canvas: { document: { stacks: { control: [], inpaint_mask: [], raster: [], regional_guidance: [] } } }, id, + workflows: { activeWorkflowId: 'wf', entries: [] }, widgetInstances: { gallery: { createdAt: '2026-01-01T00:00:00.000Z', @@ -590,6 +598,82 @@ describe('load workflow from image', () => { }); }); +describe('video workflow loading', () => { + const video = { + boardId: 'none', + category: 'general' as const, + createdAt: '2026-07-30T00:00:00.000Z', + durationSeconds: 4, + fullUrl: '/full/clip.mp4', + height: 512, + isIntermediate: false, + kind: 'video' as const, + name: 'clip.mp4', + starred: false, + thumbnailUrl: '/thumb/clip.mp4', + width: 512, + }; + + it('answers from the item when it knows, otherwise from the video record', async () => { + await expect(actionsRef.current?.hasVideoWorkflow({ ...video, hasWorkflow: true })).resolves.toBe(true); + expect(mocks.resolveItem).not.toHaveBeenCalled(); + + mocks.resolveItem.mockResolvedValueOnce({ ...video, hasWorkflow: true }); + await expect(actionsRef.current?.hasVideoWorkflow(video)).resolves.toBe(true); + expect(mocks.resolveItem).toHaveBeenCalledWith({ kind: 'video', name: 'clip.mp4' }, expect.any(AbortSignal)); + + mocks.resolveItem.mockRejectedValueOnce(new Error('offline')); + await expect(actionsRef.current?.hasVideoWorkflow(video)).resolves.toBe(false); + }); + + it('hands the embedded workflow to the editor and opens it', async () => { + mocks.videoWorkflow.mockResolvedValueOnce({ + graph: null, + workflow: JSON.stringify({ name: 'Embedded', nodes: [] }), + }); + + await act(async () => { + await actionsRef.current?.loadVideoWorkflow(video); + }); + + expect(mocks.videoWorkflow).toHaveBeenCalledWith('clip.mp4', expect.any(AbortSignal)); + expect(mocks.openWorkbenchWidget).toHaveBeenCalledWith('workflow', { + preferredRegions: ['center'], + requireCenterView: true, + }); + expect(mocks.requestWorkflowDocumentLoad).toHaveBeenCalledWith( + { name: 'Embedded', nodes: [] }, + 'widgets.gallery.itemActions.loadWorkflow.loadedLabelVideo:Embedded' + ); + }); + + it('says so, and leaves the editor alone, when the video embeds no workflow', async () => { + await act(async () => { + await actionsRef.current?.loadVideoWorkflow(video); + }); + + expect(mocks.requestWorkflowDocumentLoad).not.toHaveBeenCalled(); + expect(mocks.openWorkbenchWidget).not.toHaveBeenCalled(); + expect(mocks.notificationsAdd).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'info', title: 'widgets.gallery.itemActions.loadWorkflow.missingVideo' }) + ); + }); + + it('reports when the workflow widget cannot open', async () => { + mocks.videoWorkflow.mockResolvedValueOnce({ graph: null, workflow: JSON.stringify({ nodes: [] }) }); + mocks.openWorkbenchWidget.mockReturnValueOnce({ ok: false, reason: 'unavailable' }); + + await act(async () => { + await actionsRef.current?.loadVideoWorkflow(video); + }); + + expect(mocks.requestWorkflowDocumentLoad).not.toHaveBeenCalled(); + expect(mocks.notificationsAdd).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'error', title: 'widgets.gallery.itemActions.loadWorkflow.failedVideo' }) + ); + }); +}); + describe('partial image mutation outcomes', () => { it('moves optimistically, then rolls back and re-applies only backend-confirmed images', async () => { mocks.getItemBoardIds.mockReturnValue(new Map([['image:locked.png', 'board-0']])); @@ -717,7 +801,7 @@ describe('mixed item mutation outcomes', () => { }); // The dialog learns where focus goes once the deletion has removed the control that asked for it. - expect(mocks.requestDeletionConfirmation).toHaveBeenCalledWith(refs, expect.any(Function), returnFocus); + expect(mocks.requestDeletionConfirmation).toHaveBeenCalledWith(refs, expect.any(Function), returnFocus, []); expect(mocks.itemDelete).not.toHaveBeenCalled(); await act(async () => { @@ -738,6 +822,36 @@ describe('mixed item mutation outcomes', () => { expect(mocks.itemDelete).toHaveBeenCalledWith(refs, expect.any(AbortSignal)); }); + it('still confirms, naming the surfaces, when the project uses an item and confirmation is off', async () => { + const refs = [ + { kind: 'image' as const, name: 'source.png' }, + { kind: 'image' as const, name: 'unused.png' }, + ]; + let executeDeletion: (() => Promise) | null = null; + mocks.requestDeletionConfirmation.mockImplementation((_itemRefs, execute) => { + executeDeletion = execute; + return Promise.resolve(); + }); + mocks.getProject.mockReturnValue( + makeMockProject('project-1', {}, { inputImage: { height: 1, image_name: 'source.png', width: 1 } }) + ); + mocks.itemDelete.mockResolvedValue({ affectedBoardIds: ['none'], failed: [], succeeded: refs }); + + await act(async () => { + await getItemActions().deleteItems(refs); + }); + + expect(mocks.requestDeletionConfirmation).toHaveBeenCalledWith(refs, expect.any(Function), undefined, [ + { names: ['source.png'], surface: 'upscale' }, + ]); + expect(mocks.itemDelete).not.toHaveBeenCalled(); + + await act(async () => { + await executeDeletion?.(); + }); + expect(mocks.itemDelete).toHaveBeenCalledWith(refs, expect.any(AbortSignal)); + }); + it('uses the localized Uncategorized label in move notifications', async () => { const refs = [{ kind: 'image' as const, name: 'image.png' }]; mocks.itemMoveToBoard.mockResolvedValue({ affectedBoardIds: ['none'], failed: [], succeeded: refs }); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index 7fec9fa100f..7bfbf18a13a 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -63,7 +63,7 @@ import type { RequestDeletionConfirmation } from './useDeletionConfirmation'; import { appendReferenceImage } from './appendReferenceImage'; import { recordCanvasImportError } from './canvasImportError'; -import { executeImageRecall, executeLoadImageWorkflow, getCurrentGenerateValues } from './executeImageRecall'; +import { executeImageRecall, executeLoadMediaWorkflow, getCurrentGenerateValues } from './executeImageRecall'; import { appendReferenceVideo, executeVideoRecall, @@ -87,6 +87,7 @@ import { type ImageRecallCapabilities, type ImageRecallKind, } from './imageRecall'; +import { collectMediaUsage } from './mediaUsage'; import { EMPTY_VIDEO_RECALL_CAPABILITIES, getVideoRecallCapabilities as deriveVideoRecallCapabilitiesFromMetadata, @@ -121,6 +122,10 @@ export interface ImageActions extends GalleryItemActions { loadImageWorkflow: (image: GalleryImage) => Promise; /** Recall availability for a gallery video, from its recorded core_metadata. */ getVideoRecallCapabilities: (item: GalleryVideoItem, signal?: AbortSignal) => Promise; + /** Whether the video embeds a workflow; the listing does not say, so an unknown answer reads the record. */ + hasVideoWorkflow: (item: GalleryVideoItem, signal?: AbortSignal) => Promise; + /** Replaces the project graph with the workflow embedded in the video and opens the editor on it. */ + loadVideoWorkflow: (item: GalleryVideoItem) => Promise; moveImagesToBoard: (imageNames: string[], boardId: string) => Promise; openImageInPreview: (image: GalleryImage) => void; recallImageData: (image: GalleryImage, kind: ImageRecallKind) => Promise; @@ -534,10 +539,16 @@ export const useImageActions = ({ }, }); }; - const deleteItems: ImageActions['deleteItems'] = (items, options) => - confirmImageDeletion - ? requestDeletionConfirmation(items, () => deleteItemsConfirmed(items), options?.returnFocus) + const deleteItems: ImageActions['deleteItems'] = (items, options) => { + const project = projectId ? queries.getProject(projectId) : queries.getSnapshot().activeProject; + const usage = project ? collectMediaUsage(project, items) : []; + + // Media the project still uses is always confirmed: the opt-out covers routine deletions, not ones that + // leave authored work pointing at nothing. + return confirmImageDeletion || usage.length > 0 + ? requestDeletionConfirmation(items, () => deleteItemsConfirmed(items), options?.returnFocus, usage) : deleteItemsConfirmed(items); + }; const moveItemsToBoard = (items: GalleryItemRef[], boardId: string): Promise => { // On partial move failure, restore then reapply confirmed items. Capture prior boards from cache and store, // preferring cache; invalidation reconciles conflicts. @@ -920,9 +931,9 @@ export const useImageActions = ({ } }, loadImageWorkflow: (image) => - executeLoadImageWorkflow({ - image, + executeLoadMediaWorkflow({ isProjectActive: () => !projectId || queries.isActiveProject(projectId), + media: { kind: 'image', name: image.imageName }, notifications, openWorkflowEditor: () => openWorkbenchWidget('workflow', { preferredRegions: ['center'], requireCenterView: true }).ok, @@ -941,6 +952,31 @@ export const useImageActions = ({ return EMPTY_VIDEO_RECALL_CAPABILITIES; } }, + hasVideoWorkflow: async (item, signal) => { + if (item.hasWorkflow !== undefined) { + return item.hasWorkflow; + } + + const owner = captureAccountScope(); + + try { + const requestSignal = signal ? AbortSignal.any([signal, owner.signal]) : owner.signal; + const record = await galleryItems.resolve({ kind: 'video', name: item.name }, requestSignal); + + return record.kind === 'video' && record.hasWorkflow === true; + } catch { + return false; + } + }, + loadVideoWorkflow: (item) => + executeLoadMediaWorkflow({ + isProjectActive: () => !projectId || queries.isActiveProject(projectId), + media: { kind: 'video', name: item.name }, + notifications, + openWorkflowEditor: () => + openWorkbenchWidget('workflow', { preferredRegions: ['center'], requireCenterView: true }).ok, + t, + }), recallVideoData: async (item, kind) => { const owner = captureAccountScope(); const didRecall = await executeVideoRecall({ diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts b/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts index c51c116f5e7..8fd7715ecc3 100644 --- a/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts @@ -305,7 +305,7 @@ describe('submitResolvedInvocation with a random dynamic prompt', () => { const drawn = { positivePrompts: ['a green cat'], positivePromptsSampleSeed: 7 }; - const submitDrawn = async (sourceId: 'canvas' | 'generate') => { + const submitDrawn = async (sourceId: 'canvas' | 'generate', prepend?: boolean) => { const model: MainModelConfig = { base: 'sd-1', key: 'main', name: 'Main', type: 'main' }; const initial = createInitialWorkbenchState(); const state = workbenchReducer( @@ -347,6 +347,7 @@ describe('submitResolvedInvocation with a random dynamic prompt', () => { models: undefined, owner: captureAccountScope(), prepareCanvasInvocation, + ...(prepend === undefined ? {} : { prepend }), project, route, }); @@ -367,4 +368,87 @@ describe('submitResolvedInvocation with a random dynamic prompt', () => { expect(submitResolved).not.toHaveBeenCalled(); expect(prepareCanvasInvocation).toHaveBeenCalledWith(expect.objectContaining({ expansion: drawn })); }); + + it('keeps Invoke Front through prompt expansion to the Generate dispatch and the Canvas preparation', async () => { + const generate = await submitDrawn('generate', true); + const canvas = await submitDrawn('canvas', true); + + expect(generate.submitResolved.mock.calls[0]?.[0]).toMatchObject({ prepend: true }); + expect(canvas.prepareCanvasInvocation).toHaveBeenCalledWith(expect.objectContaining({ prepend: true })); + }); +}); + +describe('submitResolvedInvocation without dynamic prompts', () => { + seedArchitectureCapabilities(); + + const submitGenerate = async (prepend?: boolean) => { + const model: MainModelConfig = { base: 'sd-1', key: 'main', name: 'Main', type: 'main' }; + const project = getActiveProject( + workbenchReducer(createInitialWorkbenchState(), { + type: 'setGenerateSettings', + values: { ...getDefaultGenerateSettings(model), model, modelKey: model.key, positivePrompt: 'a cat' }, + }) + ); + const route = resolveInvocationRoute(project, 'global', { + destination: 'gallery', + destinationLocked: false, + sourceId: 'generate', + sourceLocked: false, + }); + const commands = createWorkbenchStore().commands; + const submitResolved = vi.spyOn(commands.generation, 'submitResolved'); + + expect(route.validationReasons).toEqual([]); + + await submitResolvedInvocation({ + commands, + formatControlLayerError, + models: undefined, + owner: captureAccountScope(), + prepareCanvasInvocation: vi.fn(), + ...(prepend === undefined ? {} : { prepend }), + project, + route, + }); + + return submitResolved; + }; + + it('dispatches a plain Generate prompt to the front of the queue only for Invoke Front', async () => { + const front = await submitGenerate(true); + const back = await submitGenerate(); + + expect(front).toHaveBeenCalledTimes(1); + expect(front.mock.calls[0]?.[0]).toMatchObject({ prepend: true }); + // No expansion took this path. + expect(front.mock.calls[0]?.[0]).not.toHaveProperty('positivePrompts'); + expect(back.mock.calls[0]?.[0]).not.toHaveProperty('prepend'); + }); + + it('keeps Invoke Front on a Workflow dispatch', async () => { + generatorsMock.resolveWorkflowGenerators.mockImplementation( + (_client: unknown, pending: Array<{ key: string; nodeId: string }>) => + Promise.resolve({ + errors: [], + resolutions: Object.fromEntries(pending.map((item) => [item.nodeId, { items: ['a cat'], key: item.key }])), + }) + ); + const project = buildProject(); + const commands = createWorkbenchStore().commands; + const submitResolved = vi.spyOn(commands.generation, 'submitResolved'); + + await submitResolvedInvocation({ + commands, + formatControlLayerError, + models: undefined, + owner: captureAccountScope(), + prepareCanvasInvocation: vi.fn(), + prepend: true, + project, + route: resolveInvocationRoute(project, 'global', workflowRoute), + }); + + expect(submitResolved).toHaveBeenCalledTimes(1); + expect(submitResolved.mock.calls[0]?.[0]).toMatchObject({ prepend: true }); + }); }); diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts b/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts index e759d3abb69..10bfe9166c5 100644 --- a/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts @@ -52,6 +52,8 @@ export interface SubmitResolvedInvocationDeps { workflowId?: string; /** Identity lifetime that initiated this submission. */ owner: AccountScope; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; commands: Pick; /** * Resolves after Canvas preparation queues or reports failure; Canvas never dispatches @@ -178,6 +180,7 @@ export const submitResolvedInvocation = async ({ models, owner, prepareCanvasInvocation, + prepend, project, route, workflowId, @@ -236,7 +239,17 @@ export const submitResolvedInvocation = async ({ } await dispatchResolvedInvocation( - { commands, formatControlLayerError, models, owner, prepareCanvasInvocation, project, route, workflowId }, + { + commands, + formatControlLayerError, + models, + owner, + prepareCanvasInvocation, + prepend, + project, + route, + workflowId, + }, expansion.prompts.length > 0 ? { positivePrompts: expansion.prompts, positivePromptsSampleSeed: sampleSeed } : undefined, @@ -247,7 +260,7 @@ export const submitResolvedInvocation = async ({ } await dispatchResolvedInvocation( - { commands, formatControlLayerError, models, owner, prepareCanvasInvocation, project, route, workflowId }, + { commands, formatControlLayerError, models, owner, prepareCanvasInvocation, prepend, project, route, workflowId }, undefined, workflowGenerators, workflowDocument?.id @@ -261,6 +274,7 @@ const dispatchResolvedInvocation = async ( models, owner, prepareCanvasInvocation, + prepend, project, route, }: SubmitResolvedInvocationDeps, @@ -280,6 +294,7 @@ const dispatchResolvedInvocation = async ( models, owner, expansion, + ...(prepend ? { prepend } : {}), projectId: project.id, canvasValues: getProjectWidgetValues(project, 'canvas'), projectSettings: project.settings, @@ -292,6 +307,7 @@ const dispatchResolvedInvocation = async ( backendSupportsCancellation: true, models, ...expansion, + ...(prepend ? { prepend } : {}), projectId: project.id, route, ...(workflowGenerators ? { workflowGenerators } : {}), diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx new file mode 100644 index 00000000000..1acc1ae5775 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx @@ -0,0 +1,106 @@ +import type { QueueItemReadModel } from '@features/queue/contracts'; +import type * as QueuePublicApi from '@features/queue/publicApi'; +import type { QueueItemDetailReadModel } from '@features/queue/react'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { system } from '@theme/system'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QueueItemActions } from './QueueItemActions'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const readDetail = vi.hoisted(() => vi.fn<(itemId: number) => Promise>()); +vi.mock('@features/queue/publicApi', async (importOriginal) => ({ + ...(await importOriginal()), + getQueueItemDetailOptions: (itemId: number) => ({ + queryFn: () => readDetail(itemId), + queryKey: ['queue', 'item-detail', itemId], + }), +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const item = { batchId: 'batch-9', id: 9, status: 'failed' } as QueueItemReadModel; +const detail = (workflow: Record | null): QueueItemDetailReadModel => ({ + errorMessage: 'Out of memory', + errorTraceback: 'Traceback…', + errorType: 'OutOfMemoryError', + graph: { id: 'graph-9' }, + id: 9, + priority: 0, + results: {}, + sessionId: 'session-9', + status: 'failed', + workflow, +}); + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + readDetail.mockReset(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +const render = async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(() => + root.render( + + + + + + ) + ); +}; + +const button = (label: string) => + [...document.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(label)); +const dialogText = () => document.querySelector('[role="dialog"]')?.textContent ?? ''; + +describe('QueueItemActions View JSON', () => { + it('reads the saved execution data only when opened, and offers a retry when the read fails', async () => { + readDetail.mockRejectedValueOnce(new Error('Queue item 9 not found')); + await render(); + + expect(readDetail).not.toHaveBeenCalled(); + + await act(() => button('common.viewJson')!.click()); + + await vi.waitFor(() => expect(dialogText()).toContain('widgets.queue.detailLoadFailed')); + expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('Queue item 9 not found'); + + readDetail.mockResolvedValueOnce(detail(null)); + const retry = button('common.retry')!; + retry.focus(); + await act(() => retry.click()); + + await vi.waitFor(() => expect(dialogText()).toContain('widgets.queue.detailViews.graph')); + // The Retry button is gone; focus lands on the view now shown instead of falling to the page. + expect(document.activeElement?.getAttribute('role')).toBe('tab'); + expect(document.activeElement?.getAttribute('aria-selected')).toBe('true'); + expect(dialogText()).not.toContain('widgets.queue.detailViews.workflow'); + expect(readDetail).toHaveBeenCalledTimes(2); + }); + + it('adds a Workflow view when the batch embedded one', async () => { + readDetail.mockResolvedValue(detail({ name: 'Upscale' })); + await render(); + + await act(() => button('common.viewJson')!.click()); + + await vi.waitFor(() => expect(dialogText()).toContain('widgets.queue.detailViews.workflow')); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx index d4d21d1c721..a3096221ef1 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx @@ -1,14 +1,116 @@ import type { QueueItemReadModel } from '@features/queue/contracts'; +import type { QueueItemDetailReadModel } from '@features/queue/react'; -import { ButtonGroup, Icon, Portal } from '@chakra-ui/react'; +import { ButtonGroup, Icon, Portal, Spinner, Stack, Text } from '@chakra-ui/react'; +import { useQueueItemDetail } from '@features/queue/react'; import { Button, CloseButton } from '@platform/ui/Button'; import { Dialog } from '@platform/ui/Dialog'; import { JsonPreview } from '@platform/ui/JsonPreview'; +import { Tabs } from '@platform/ui/Tabs'; import { useNotify } from '@workbench/useNotify'; import { FileTextIcon, WandSparklesIcon } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +type DetailView = 'graph' | 'item' | 'results' | 'workflow'; + +const getDetailViews = (detail: QueueItemDetailReadModel): DetailView[] => [ + 'item', + 'graph', + 'results', + ...(detail.workflow ? (['workflow'] as const) : []), +]; + +const getDetailValue = (item: QueueItemReadModel, detail: QueueItemDetailReadModel, view: DetailView): unknown => { + switch (view) { + case 'graph': + return detail.graph; + case 'results': + return detail.results; + case 'workflow': + return detail.workflow; + default: + return { ...item, errorTraceback: detail.errorTraceback, priority: detail.priority }; + } +}; + +/** The saved execution data, read when the dialog opens so a failed or outputless job can still be inspected. */ +const QueueItemDetailBody = ({ item, open }: { item: QueueItemReadModel; open: boolean }) => { + const { t } = useTranslation(); + const { detail, error, loadState, retry, retrying } = useQueueItemDetail(item.id, open); + const [view, setView] = useState('item'); + const onViewChange = useCallback((details: { value: string }) => setView(details.value as DetailView), []); + // A successful retry replaces the focused Retry button with the views; focus follows onto the selected one. + const focusAfterRetryRef = useRef(false); + const onRetry = useCallback(() => { + focusAfterRetryRef.current = true; + retry(); + }, [retry]); + const focusRestoredViews = useCallback((node: HTMLDivElement | null) => { + if (node && focusAfterRetryRef.current) { + focusAfterRetryRef.current = false; + node.querySelector('[role="tab"][aria-selected="true"]')?.focus(); + } + }, []); + + if (loadState === 'error') { + return ( + + + + {t('widgets.queue.detailLoadFailed')} + + {error ? ( + + {error} + + ) : null} + + + + ); + } + + if (!detail) { + return ( + + + + {t('widgets.queue.detailLoading')} + + + ); + } + + const views = getDetailViews(detail); + const activeView = views.includes(view) ? view : 'item'; + + return ( + + + {views.map((candidate) => ( + + {t(`widgets.queue.detailViews.${candidate}`)} + + ))} + + {views.map((candidate) => ( + + + + ))} + + ); +}; + export const QueueItemActions = ({ item }: { item: QueueItemReadModel }) => { const { t } = useTranslation(); const notify = useNotify(); @@ -24,7 +126,7 @@ export const QueueItemActions = ({ item }: { item: QueueItemReadModel }) => { return ( <> - + + + {restartNotice} + + ); + } + + return ( + + + {t('settings.maxQueueHistory.description')} + + + + + {typeof maxQueueHistory === 'number' ? ( + + ) : null} + + {parsed === 'invalid' ? ( + + {t('settings.maxQueueHistory.invalid')} + + ) : null} + {saveError ? ( + + {saveError} + + ) : null} + {restartNotice} + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts b/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts index a8dc59cd1be..76f96611aae 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts @@ -153,6 +153,7 @@ export const developerSettings = section('developer', 'Developer', [ ]); export const serverSettings = section('server', 'Server', [ custom('generationDevices', 'Generation devices', 'server'), + custom('maxQueueHistory', 'Queue history', 'server'), ]); export const workspaceSettings = section('workspace', 'Data & workspace', [ custom('workspaceActions', 'Data & workspace', 'none'), diff --git a/invokeai/frontend/webv2/src/workbench/widgetState.ts b/invokeai/frontend/webv2/src/workbench/widgetState.ts index d2474fe83e7..7da8104c270 100644 --- a/invokeai/frontend/webv2/src/workbench/widgetState.ts +++ b/invokeai/frontend/webv2/src/workbench/widgetState.ts @@ -8,15 +8,20 @@ const emptyWidgetState = (widgetId: WidgetTypeId): WidgetStateContract => ({ version: 1, }); +/** The widget instances alone: readers of a widget's values need nothing else from the project. */ +export type WidgetInstanceHolder = Pick; + export const getProjectWidgetInstance = ( - project: Project, + project: WidgetInstanceHolder, widgetId: WidgetTypeId ): WidgetInstanceContract | undefined => project.widgetInstances[widgetId] ?? Object.values(project.widgetInstances).find((instance) => instance.typeId === widgetId); -export const getProjectWidgetState = (project: Project, widgetId: WidgetTypeId): WidgetStateContract => +export const getProjectWidgetState = (project: WidgetInstanceHolder, widgetId: WidgetTypeId): WidgetStateContract => getProjectWidgetInstance(project, widgetId)?.state ?? emptyWidgetState(widgetId); -export const getProjectWidgetValues = (project: Project, widgetId: WidgetTypeId): Record => - getProjectWidgetState(project, widgetId).values; +export const getProjectWidgetValues = ( + project: WidgetInstanceHolder, + widgetId: WidgetTypeId +): Record => getProjectWidgetState(project, widgetId).values; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts index 2b2aaf5dd48..9c8bf377fcd 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts @@ -506,6 +506,18 @@ describe('runCanvasInvocation', () => { expect(submitted[0]!.graph.backendGraph!.nodes.canvas_output!.is_intermediate).toBe(false); }); + it('carries Invoke Front to the queued Canvas snapshot only when asked', async () => { + const appended = makeHarness({ document: makeDoc([]) }); + const front = makeHarness({ document: makeDoc([]) }); + front.deps.prepend = true; + + await runCanvasInvocation(appended.deps); + await runCanvasInvocation(front.deps); + + expect(appended.submittedGraphs()[0]).not.toHaveProperty('prepend'); + expect(front.submittedGraphs()[0]?.prepend).toBe(true); + }); + it('marks the output intermediate for a Canvas destination (staging)', async () => { const harness = makeHarness({ destination: 'canvas', document: makeDoc([]) }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts index 230fa3ab377..ae525b4b8e3 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts @@ -86,6 +86,8 @@ export interface RunCanvasInvocationDeps { composeForGeneration: (options: ComposeForGenerationOptions) => Promise; /** Expanded by the caller before submitting. */ expansion?: ExpandedPositivePrompts; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; /** Cancels raster capture for this invocation. */ signal: AbortSignal; /** Paint-bitmap persistence barrier, awaited before compositing. */ @@ -371,6 +373,7 @@ export const runCanvasInvocation = async (deps: RunCanvasInvocationDeps): Promis graph: compiled.graph, canvas: composites.canvas, ...deps.expansion, + ...(deps.prepend ? { prepend: true } : {}), projectId, }); composed.dedupeCommit.commit(); @@ -412,6 +415,8 @@ export interface PrepareCanvasInvocationArgs { owner?: AccountScope; /** Expanded by the caller before submitting. */ expansion?: ExpandedPositivePrompts; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; projectSettings: GenerationProjectSettings; /** * The canvas widget's persisted values. The scaling policy is read here, in @@ -460,6 +465,7 @@ export const prepareCanvasInvocation = async (args: PrepareCanvasInvocationArgs) inFlight: inFlightProjects, inFlightKey: `${owner.epoch}:${args.projectId}`, models: args.models, + ...(args.prepend ? { prepend: true } : {}), projectId: args.projectId, projectSettings: args.projectSettings, randDevice: resolveRandDeviceMetadata(args.projectSettings.useCpuNoise, getGenerationDevicesSnapshot().options), diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx index bd7f087ef08..aaba17e2035 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx @@ -3289,6 +3289,30 @@ describe('preview keyboard navigation boundary', () => { expect([...registeredHotkeys.keys()]).not.toContain('viewer.zoomToFit'); }); + it('binds I to toggling the details popover through the preview widget value', async () => { + await render(); + + expect(registeredHotkeys.get('viewer.toggleMetadata')).toEqual(['i']); + const toggle = registeredCommands.get('viewer.toggleMetadata'); + expect(toggle).toBeDefined(); + + await act(async () => { + toggle?.(); + await Promise.resolve(); + }); + + expect(mocks.commands.widgets.patchValues).toHaveBeenLastCalledWith('preview', { metadataOpen: true }); + + mocks.project.widgetInstances.preview.state.values = { metadataOpen: true }; + await render(); + await act(async () => { + registeredCommands.get('viewer.toggleMetadata')?.(); + await Promise.resolve(); + }); + + expect(mocks.commands.widgets.patchValues).toHaveBeenLastCalledWith('preview', { metadataOpen: false }); + }); + it('retains compare and zoom hotkey registrations for images', async () => { await render(); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx index 465aac1c6f1..99c4c38719a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx @@ -88,7 +88,12 @@ import { type PreviewVideoFrameCopyResult, } from './PreviewFrame'; import { previewHeaderStore, previewStageStore, type PreviewZoomCommands } from './previewHeaderStore'; -import { getPreviewComparisonMode, getPreviewFilmstripVisible, type PreviewComparisonMode } from './previewSettings'; +import { + getPreviewComparisonMode, + getPreviewFilmstripVisible, + getPreviewMetadataOpen, + type PreviewComparisonMode, +} from './previewSettings'; import { usePreviewNavigation } from './usePreviewNavigation'; /** For the live footer's Details slot, which renders disabled and never fires. */ @@ -579,6 +584,11 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { if (commandId === 'viewer.toggleFilmstrip') { widgets.patchValues('preview', { filmstripVisible: !getPreviewFilmstripVisible(previewValues) }); + return; + } + + if (commandId === 'viewer.toggleMetadata') { + widgets.patchValues('preview', { metadataOpen: !getPreviewMetadataOpen(previewValues) }); } }); @@ -586,6 +596,7 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { const hotkeys = [ ['viewer.deleteImage', t('widgets.preview.commands.deletePreviewImage'), ['delete', 'backspace']], ['viewer.toggleFilmstrip', t('widgets.preview.commands.toggleFilmstrip'), ['t']], + ['viewer.toggleMetadata', t('widgets.preview.commands.toggleMetadata'), ['i']], // Widget-scoped, so the arrows step wherever Preview holds the keys: focus inside it, or on the region it was // just opened in. Comparison does not step saved images, so it leaves the arrows unclaimed. ...(isComparing diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index 26f90f889e1..da47761218a 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -452,6 +452,55 @@ const primeGenerate = ( const submitGenerate = (state: WorkbenchState) => workbenchReducer(state, { backendSupportsCancellation: true, type: 'submitInvocationSnapshot' }); +describe('Invoke Front', () => { + const getQueuedSnapshot = (state: WorkbenchState) => getActiveProject(state).queue.items[0]?.snapshot; + + it('records front priority on resolved submissions only when the command asks for it', () => { + const submitResolved = (prepend?: boolean) => { + const state = primeGenerate(); + + return workbenchReducer(state, { + backendSupportsCancellation: true, + ...(prepend === undefined ? {} : { prepend }), + projectId: state.activeProjectId, + route: { destination: 'gallery', destinationLocked: false, sourceId: 'generate', sourceLocked: false }, + type: 'submitResolvedInvocationSnapshot', + }); + }; + + expect(getQueuedSnapshot(submitResolved())).not.toHaveProperty('prepend'); + expect(getQueuedSnapshot(submitResolved(true))?.prepend).toBe(true); + }); + + it('records front priority on prepared Canvas submissions', () => { + const state = primeGenerate(); + const queued = workbenchReducer(state, { + backendSupportsCancellation: true, + canvas: structuredClone(getActiveProject(state).canvas), + destination: 'canvas', + generate: { + negativePromptNodeId: 'negative_prompt', + positivePromptNodeId: 'positive_prompt', + seedNodeId: 'seed', + values: createGenerateValues({ seed: 1, seedMode: 'fixed' }), + }, + graph: { + edges: [], + id: 'canvas-graph', + label: 'Canvas', + nodes: [], + updatedAt: '2026-06-09T00:00:00.000Z', + version: 1, + }, + prepend: true, + projectId: state.activeProjectId, + type: 'submitCanvasInvocationSnapshot', + }); + + expect(getQueuedSnapshot(queued)?.prepend).toBe(true); + }); +}); + const getQueuedOutputNode = (state: WorkbenchState) => { const submission = getActiveProject(state).queue.items[0]?.snapshot.backendSubmission; const nodes = submission?.kind === 'invalid' ? {} : (submission?.graph.nodes ?? {}); @@ -2284,6 +2333,267 @@ describe('workbenchReducer Phase 5 generation flow', () => { expect(activeProject.queue.items).toEqual([]); }); + it('reopens a failed Canvas run around its rerun and stages the rerun at the submitted bbox', () => { + let state = submitGenerate(primeGenerate()); + const project = getActiveProject(state); + const queueItem = project.queue.items[0]!; + const submittedBbox = queueItem.snapshot.canvas.document.bbox; + + state = workbenchReducer(state, { + backendItemIds: [41], + projectId: project.id, + queueItemId: queueItem.id, + type: 'markQueueItemBackendSubmitted', + }); + state = workbenchReducer(state, { + error: 'Out of memory', + notify: false, + projectId: project.id, + queueItemId: queueItem.id, + status: 'failed', + type: 'setQueueItemStatus', + }); + state = workbenchReducer(state, { + bbox: { ...submittedBbox, x: submittedBbox.x + 64 }, + type: 'setCanvasBbox', + }); + state = workbenchReducer(state, { + projectId: project.id, + queueItemId: queueItem.id, + // A rerun of another run's item is not this run's to adopt. + retriedItems: [ + { itemId: 99, sourceItemId: 41 }, + { itemId: 100, sourceItemId: 7 }, + ], + type: 'reopenQueueItemForRetry', + }); + + const reopened = getProject(state, project.id).queue.items[0]; + expect(reopened).toEqual(expect.objectContaining({ backendItemIds: [99], status: 'running' })); + expect(reopened).not.toHaveProperty('error'); + + state = workbenchReducer(state, { + images: [createImage('rerun.png', queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'routeQueueItemResults', + }); + + const { stagingArea } = getProject(state, project.id).canvas; + expect(stagingArea.pendingImageIds).toEqual(['rerun.png']); + expect(stagingArea.pendingImages[0]).toEqual( + expect.objectContaining({ + placement: expect.objectContaining({ x: submittedBbox.x, y: submittedBbox.y }), + sourceBackendItemId: 99, + }) + ); + }); + + it('keeps the staged candidate selected when a retry reopens an earlier run', () => { + let state = submitGenerate(primeGenerate()); + const projectId = state.activeProjectId; + const runA = getProject(state, projectId).queue.items[0]!; + state = workbenchReducer(state, { mode: 'off', type: 'setCanvasStagingAutoSwitch' }); + state = workbenchReducer(state, { + backendItemIds: [41, 42], + projectId, + queueItemId: runA.id, + type: 'markQueueItemBackendSubmitted', + }); + state = workbenchReducer(state, { + backendItemId: 41, + images: [createImage('a1.png', runA.id)], + projectId, + queueItemId: runA.id, + type: 'routeQueueItemPartialResults', + }); + state = workbenchReducer(state, { + notify: false, + projectId, + queueItemId: runA.id, + status: 'failed', + type: 'setQueueItemStatus', + }); + state = submitGenerate(state); + const runB = getProject(state, projectId).queue.items.find((item) => item.id !== runA.id)!; + state = workbenchReducer(state, { + backendItemIds: [51], + projectId, + queueItemId: runB.id, + type: 'markQueueItemBackendSubmitted', + }); + state = workbenchReducer(state, { + images: [createImage('b1.png', runB.id), createImage('b2.png', runB.id)], + projectId, + queueItemId: runB.id, + type: 'routeQueueItemResults', + }); + const selectedImageName = () => { + const project = getProject(state, projectId); + const slot = getCanvasStagingSlots(project.canvas, project.queue.items)[ + project.canvas.stagingArea.selectedImageIndex + ]; + return slot?.kind === 'candidate' ? slot.candidate.imageName : slot?.kind; + }; + const slotsBefore = getCanvasStagingSlots( + getProject(state, projectId).canvas, + getProject(state, projectId).queue.items + ); + state = workbenchReducer(state, { + imageIndex: slotsBefore.findIndex((slot) => slot.kind === 'candidate' && slot.candidate.imageName === 'b2.png'), + type: 'setStagedImageIndex', + }); + expect(selectedImageName()).toBe('b2.png'); + + state = workbenchReducer(state, { + projectId, + queueItemId: runA.id, + retriedItems: [{ itemId: 99, sourceItemId: 42 }], + type: 'reopenQueueItemForRetry', + }); + + expect(getProject(state, projectId).queue.items.find((item) => item.id === runA.id)?.status).toBe('running'); + expect(selectedImageName()).toBe('b2.png'); + }); + + it('leaves a run finished when it is still active or its Canvas document was replaced', () => { + let state = submitGenerate(primeGenerate()); + const project = getActiveProject(state); + const queueItem = project.queue.items[0]!; + const reopen = (current: WorkbenchState) => + workbenchReducer(current, { + projectId: project.id, + queueItemId: queueItem.id, + retriedItems: [{ itemId: 99, sourceItemId: 41 }], + type: 'reopenQueueItemForRetry', + }); + + state = workbenchReducer(state, { + backendItemIds: [41], + projectId: project.id, + queueItemId: queueItem.id, + type: 'markQueueItemBackendSubmitted', + }); + expect(reopen(state)).toBe(state); + + state = workbenchReducer(state, { + notify: false, + projectId: project.id, + queueItemId: queueItem.id, + status: 'failed', + type: 'setQueueItemStatus', + }); + state = workbenchReducer(state, { document: createEmptyCanvasDocument(), type: 'replaceCanvasDocument' }); + expect(reopen(state)).toBe(state); + }); + + it('returns a later retry of another item the run left behind to that run, keeping its earlier results', () => { + let state = submitGenerate(primeGenerate()); + const project = getActiveProject(state); + const queueItem = project.queue.items[0]!; + const reopen = (current: WorkbenchState, itemId: number, sourceItemId: number) => + workbenchReducer(current, { + projectId: project.id, + queueItemId: queueItem.id, + retriedItems: [{ itemId, sourceItemId }], + type: 'reopenQueueItemForRetry', + }); + const finish = (current: WorkbenchState, imageName: string) => + workbenchReducer(current, { + images: [createImage(imageName, queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'routeQueueItemResults', + }); + + state = workbenchReducer(state, { + backendItemIds: [40, 41, 42], + projectId: project.id, + queueItemId: queueItem.id, + type: 'markQueueItemBackendSubmitted', + }); + state = workbenchReducer(state, { + backendItemId: 40, + images: [createImage('first.png', queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'routeQueueItemPartialResults', + }); + state = workbenchReducer(state, { + notify: false, + projectId: project.id, + queueItemId: queueItem.id, + status: 'failed', + type: 'setQueueItemStatus', + }); + + state = finish(reopen(state, 99, 41), 'rerun-41.png'); + expect(getProject(state, project.id).queue.items[0]).toEqual( + expect.objectContaining({ + resultImages: [ + expect.objectContaining({ imageName: 'first.png' }), + expect.objectContaining({ imageName: 'rerun-41.png' }), + ], + status: 'completed', + }) + ); + + state = reopen(state, 100, 42); + expect(getProject(state, project.id).queue.items[0]).toEqual( + expect.objectContaining({ backendItemIds: [100], status: 'running' }) + ); + }); + + it('stages a rerun its active run could not adopt where that run stages', () => { + let state = submitGenerate(primeGenerate()); + const project = getActiveProject(state); + const queueItem = project.queue.items[0]!; + const submittedBbox = queueItem.snapshot.canvas.document.bbox; + + state = workbenchReducer(state, { + bbox: { ...submittedBbox, x: submittedBbox.x + 64 }, + type: 'setCanvasBbox', + }); + state = workbenchReducer(state, { + backendItemId: 100, + images: [createImage('rerun.png', queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'stageRetriedCanvasResults', + }); + + expect(getCanvas(state).stagingArea.pendingImages[0]).toEqual( + expect.objectContaining({ placement: expect.objectContaining({ x: submittedBbox.x, y: submittedBbox.y }) }) + ); + }); + + it('stages a rerun no run adopted at the current bbox', () => { + let state = primeGenerate(); + const projectId = state.activeProjectId; + const initialBbox = getCanvas(state).document.bbox; + + state = workbenchReducer(state, { bbox: { ...initialBbox, x: initialBbox.x + 64 }, type: 'setCanvasBbox' }); + const currentBbox = getCanvas(state).document.bbox; + expect(currentBbox.x).not.toBe(initialBbox.x); + state = workbenchReducer(state, { + backendItemId: 99, + images: [createImage('rerun.png', 'run-from-another-tab')], + projectId, + queueItemId: 'run-from-another-tab', + type: 'stageRetriedCanvasResults', + }); + + const { stagingArea } = getCanvas(state); + expect(stagingArea.pendingImageIds).toEqual(['rerun.png']); + expect(stagingArea.isVisible).toBe(true); + expect(stagingArea.pendingImages[0]).toEqual( + expect.objectContaining({ + placement: expect.objectContaining({ x: currentBbox.x, y: currentBbox.y }), + sourceBackendItemId: 99, + }) + ); + }); + it("keeps the selected workflow candidate when auto-switch is 'off'", () => { let state = createInitialWorkbenchState(); const projectId = state.activeProjectId; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 4a233f8afb0..4bd67fd72eb 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -150,7 +150,7 @@ import { sanitizeBatchCount, syncGenerateWidgetValuesWithModels, } from '@features/generation/settings'; -import { MAX_QUEUE_BATCH_ITEMS } from '@features/queue'; +import { getQueueSourceResultSelection, MAX_QUEUE_BATCH_ITEMS } from '@features/queue'; import { getGenerationDevicesSnapshot, resolveRandDeviceMetadata } from '@features/queue/devices'; import { clearDeletedUpscaleInput, @@ -389,6 +389,8 @@ type WorkbenchReducerAction = | ({ type: 'submitResolvedInvocationSnapshot'; backendSupportsCancellation: boolean; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; /** Async workflow generator outputs, resolved by the caller before dispatch. */ workflowGenerators?: WorkflowGeneratorResolutions; route: InvocationRoute; @@ -437,6 +439,19 @@ type WorkbenchReducerAction = videos?: GeneratedVideoContract[]; } | { type: 'restoreQueueItemsFromJournal'; projectId: string; items: unknown[] } + | { + type: 'reopenQueueItemForRetry'; + projectId: string; + queueItemId: string; + retriedItems: { itemId: number; sourceItemId: number }[]; + } + | { + type: 'stageRetriedCanvasResults'; + projectId: string; + queueItemId: string; + backendItemId: number; + images: GeneratedImageContract[]; + } | { type: 'appendCanvasStagingCandidate'; projectId: string; candidate: CanvasStagingCandidateContract } | { type: 'selectGalleryItem'; @@ -510,6 +525,8 @@ type WorkbenchReducerAction = destination: ResultDestination; generate: QueueGenerateSnapshot; graph: GraphContract; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; projectId: string; } & Partial) | { @@ -967,15 +984,49 @@ const stageCanvasResultImages = ( ): Project => { const queueItem = project.queue.items.find((item) => item.id === queueItemId); - if ( - images.length === 0 || - !queueItem || - queueItem.snapshot.canvas.documentRevision !== project.canvas.documentRevision - ) { + if (!queueItem || queueItem.snapshot.canvas.documentRevision !== project.canvas.documentRevision) { + return project; + } + + return stageCanvasImages( + project, + queueItemId, + queueItem.snapshot.canvas.document, + images, + sourceBackendItemIds, + previousSelectedSlot + ); +}; + +/** + * Where a rerun no run adopted stages: where its run would have while that document is still open, else at the current + * bbox. + */ +const getRetriedCanvasResultDocument = ( + project: Project, + queueItemId: string +): Pick => { + const queueItem = project.queue.items.find((item) => item.id === queueItemId); + + return queueItem?.snapshot.destination === 'canvas' && + queueItem.snapshot.canvas.documentRevision === project.canvas.documentRevision + ? queueItem.snapshot.canvas.document + : project.canvas.document; +}; + +/** Stage images at `queueDocument`'s bbox: the submitted document's, or the current one for a run nobody captured. */ +const stageCanvasImages = ( + project: Project, + queueItemId: string, + queueDocument: Pick, + images: GeneratedImageContract[], + sourceBackendItemIds?: readonly (number | undefined)[], + previousSelectedSlot?: CanvasStagingSlot +): Project => { + if (images.length === 0) { return project; } - const queueDocument = queueItem.snapshot.canvas.document; const { bbox } = queueDocument; const incomingImages = images.map((image, index) => normalizeStagingCandidate( @@ -3176,6 +3227,50 @@ const mergeImageResults = ( return [...existing, ...incomingImages.filter((image) => !existingNames.has(image.imageName))]; }; +/** + * Reopen a finished run around the backend items that re-run its failed or canceled ones, so its captured destination + * receives their results. A run already active, or a Canvas run whose document was since replaced, stays finished. + */ +const reopenQueueItemForRetry = ( + project: Project, + queueItemId: string, + retriedItems: readonly { itemId: number; sourceItemId: number }[] +): Project => { + const queueItem = project.queue.items.find((item) => item.id === queueItemId); + const ownedItemIds = new Set([...(queueItem?.backendItemIds ?? []), ...(queueItem?.retriedBackendItemIds ?? [])]); + const itemIds = retriedItems.filter(({ sourceItemId }) => ownedItemIds.has(sourceItemId)).map(({ itemId }) => itemId); + + if ( + !queueItem || + itemIds.length === 0 || + queueItem.cancellationPending === true || + (queueItem.status !== 'completed' && queueItem.status !== 'failed' && queueItem.status !== 'cancelled') || + (queueItem.snapshot.destination === 'canvas' && + queueItem.snapshot.canvas.documentRevision !== project.canvas.documentRevision) + ) { + return project; + } + + return updateQueueItem(project, queueItemId, (item) => { + const { + cancelledBackendItemIds: _cancelled, + completedBackendItemIds: _completed, + error: _error, + localRecoveryState: _localRecoveryState, + ...reopened + } = item; + + // The run tracks only the reruns from here; it remembers the items it left so that retrying another of them + // later still returns its results here. + return { + ...reopened, + backendItemIds: itemIds, + retriedBackendItemIds: [...ownedItemIds], + status: 'running', + }; + }); +}; + const mergeBackendItemId = (ids: number[] | undefined, backendItemId: number): number[] => ids?.includes(backendItemId) ? ids : [...(ids ?? []), backendItemId]; @@ -3392,7 +3487,8 @@ const routeQueueItemResults = ( completedBackendItemIds: item.backendItemIds ? item.backendItemIds.filter((backendItemId) => !item.cancelledBackendItemIds?.includes(backendItemId)) : item.completedBackendItemIds, - resultImages: images, + // A run a retry reopened keeps the results its other items delivered before. + resultImages: item.retriedBackendItemIds ? mergeImageResults(item.resultImages, images) : images, status: 'completed', })); @@ -3439,7 +3535,8 @@ const enqueueCompiledSnapshot = ( projectWorkflowId?: string; }, backendSupportsCancellation: boolean, - canvasSnapshot?: CanvasStateContractV3 + canvasSnapshot?: CanvasStateContractV3, + prepend = false ): Project => { const submittedAt = now(); const queueItemId = createId('queue-item'); @@ -3597,15 +3694,10 @@ const enqueueCompiledSnapshot = ( width: presentationDimensions.width, }, sourceId: route.sourceId, - ...(route.sourceId === 'generate' || route.sourceId === 'canvas' - ? { resultNodeIds: ['canvas_output'] } - : route.sourceId === 'upscale' - ? { resultNodeIds: ['upscale_output'] } - : route.sourceId === 'video' - ? { resultNodeIds: ['video_output'] } - : {}), + ...getQueueSourceResultSelection(route.sourceId), submittedAt, ...(recall ? { recall } : {}), + ...(prepend ? { prepend: true } : {}), }, status: 'pending', }; @@ -3686,7 +3778,8 @@ const submitInvocationSnapshot = ( models?: readonly ModelConfig[], expansion: Partial = {}, workflowGenerators?: WorkflowGeneratorResolutions, - workflowDocument?: ProjectGraphState + workflowDocument?: ProjectGraphState, + prepend = false ): Project => { if (!isInvocationRouteValid(route)) { return project; @@ -3698,7 +3791,14 @@ const submitInvocationSnapshot = ( return project; } - return enqueueCompiledSnapshot(project, route, { ...compiledSnapshot, ...expansion }, backendSupportsCancellation); + return enqueueCompiledSnapshot( + project, + route, + { ...compiledSnapshot, ...expansion }, + backendSupportsCancellation, + undefined, + prepend + ); }; export const createInitialWorkbenchState = (): WorkbenchState => { @@ -4814,7 +4914,8 @@ export const __workbenchReducerInternal = ( action.models, { positivePrompts: action.positivePrompts, positivePromptsSampleSeed: action.positivePromptsSampleSeed }, action.workflowGenerators, - workflow?.document + workflow?.document, + action.prepend === true ) ), action.projectId @@ -4981,6 +5082,30 @@ export const __workbenchReducerInternal = ( : { ...project, queue: { items: [...liveItems, ...restoredItems] } }; }); } + case 'reopenQueueItemForRetry': { + return updateProjectById(state, action.projectId, (project) => { + const reopened = reopenQueueItemForRetry(project, action.queueItemId, action.retriedItems); + + // The reopened run's slots regroup under its rerun, so keep the selection on the candidate it showed. + return reopened === project + ? project + : clampCanvasStagingSelection(reopened, getSelectedCanvasStagingSlot(project)); + }); + } + case 'stageRetriedCanvasResults': { + return updateProjectById(state, action.projectId, (project) => + clampCanvasStagingSelection( + stageCanvasImages( + project, + action.queueItemId, + getRetriedCanvasResultDocument(project, action.queueItemId), + action.images, + action.images.map(() => action.backendItemId), + getSelectedCanvasStagingSlot(project) + ) + ) + ); + } case 'appendCanvasStagingCandidate': { return updateProjectById(state, action.projectId, (project) => appendCanvasStagingCandidate(project, action.candidate) @@ -5373,7 +5498,8 @@ export const __workbenchReducerInternal = ( widgetStates: getWidgetStatesSnapshot(project.widgetInstances), }, action.backendSupportsCancellation, - action.canvas + action.canvas, + action.prepend === true ) ), action.projectId diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts index d1e89b9641e..8c998829eca 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts @@ -464,7 +464,9 @@ const createCommands = ( setLocalRecoveryState: command('setQueueItemLocalRecoveryState'), routePartialResults: command('routeQueueItemPartialResults'), routeResults: command('routeQueueItemResults'), + reopenForRetry: command('reopenQueueItemForRetry'), restoreFromJournal: command('restoreQueueItemsFromJournal'), + stageRetriedCanvasResults: command('stageRetriedCanvasResults'), setConnectionStatus: command('setBackendConnectionStatus'), setStatus: command('setQueueItemStatus'), }, diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index c060f6be154..2c6072eb156 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -53,6 +53,10 @@ const ROUTE_SHARED_MODULES = [ '/workbench/projects/editorSession.ts', '/platform/ui/BrandIcon.tsx', '/platform/ui/Button.tsx', + // The Fonts page, the Generate render section and the lazy workflow number fields share the slider controls; + // alone they cost the editor a boot request, and the editor boot chunk would drag the editor into the Launchpad. + '/platform/ui/Slider.tsx', + '/platform/ui/SliderNumberField.tsx', '/platform/ui/Tooltip.tsx', '/platform/ui/RetryBoundary.tsx', '/platform/ui/PanelHeader.tsx', diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index cf12b881833..76f32f43065 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -3094,10 +3094,17 @@ def test_queue_items_retried_payload_is_filtered_per_owner_room(self, socketio: from unittest.mock import AsyncMock from invokeai.app.services.events.events_common import QueueItemsRetriedEvent - from invokeai.app.services.session_queue.session_queue_common import RetryItemsResult + from invokeai.app.services.session_queue.session_queue_common import RetriedQueueItem, RetryItemsResult event = QueueItemsRetriedEvent.build( - RetryItemsResult(queue_id="default", retried_item_ids=[10, 20]), + RetryItemsResult( + queue_id="default", + retried_item_ids=[10, 20], + retried_items=[ + RetriedQueueItem(source_item_id=10, item_id=30), + RetriedQueueItem(source_item_id=20, item_id=31), + ], + ), user_ids=["owner-123", "owner-456"], retried_item_ids_by_user={"owner-123": [10], "owner-456": [20]}, ) @@ -3123,6 +3130,12 @@ def test_queue_items_retried_payload_is_filtered_per_owner_room(self, socketio: assert admin_calls[0].kwargs["data"]["retried_item_ids"] == [10, 20] assert admin_calls[0].kwargs["data"]["user_ids"] == ["owner-123", "owner-456"] assert admin_calls[0].kwargs["data"]["retried_item_ids_by_user"] == {"owner-123": [10], "owner-456": [20]} + # The new item ids follow the retried ids they pair with: each owner sees only its own reruns. + assert owner_123_calls[0].kwargs["data"]["retried_items"] == [{"source_item_id": 10, "item_id": 30}] + assert owner_456_calls[0].kwargs["data"]["retried_items"] == [{"source_item_id": 20, "item_id": 31}] + assert len(admin_calls[0].kwargs["data"]["retried_items"]) == 2 + sanitized_calls = [call for call in mock_emit.call_args_list if call.kwargs.get("room") == "default"] + assert sanitized_calls[0].kwargs["data"]["retried_items"] == [] def test_queue_items_canceled_routed_privately_per_owner(self, socketio: Any) -> None: """The FULL queue_items_canceled event, which carries owners' item ids, must reach only the diff --git a/tests/app/routers/test_session_queue_delivery_route.py b/tests/app/routers/test_session_queue_delivery_route.py new file mode 100644 index 00000000000..5b7c4d11ffa --- /dev/null +++ b/tests/app/routers/test_session_queue_delivery_route.py @@ -0,0 +1,141 @@ +"""The delivery route lets only the owner of a queue item (or an admin) claim, confirm or release staging its results.""" + +import json +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from pydantic_core import to_jsonable_python +from sqlalchemy import insert + +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_default import SessionQueue +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.schema.session_queue import session_queue +from invokeai.app.services.shared.execution_state_migration import dump_execution_state +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from tests.app.routers.conftest import MockApiDependencies, _auth + + +@pytest.fixture +def queue_router( + monkeypatch: pytest.MonkeyPatch, enable_multiuser: Any, mock_invoker: Invoker, mock_sqlite_database: Database +): + mock_invoker.services.session_queue = SessionQueue(mock_sqlite_database) + monkeypatch.setattr("invokeai.app.api.routers.session_queue.ApiDependencies", MockApiDependencies(mock_invoker)) + + +def _item(database: Database, user_id: str, queue_id: str = "default") -> int: + session = GraphExecutionState(graph=Graph()) + values = { + "queue_id": queue_id, + "batch_id": "batch", + "session_id": session.id, + "session": json.dumps(dump_execution_state(session), default=to_jsonable_python), + "status": "completed", + "user_id": user_id, + } + with database.begin(write=True) as conn: + return int(conn.execute(insert(session_queue).values(**values)).inserted_primary_key[0]) + + +def _deliver( + client: TestClient, token: str, item_id: int, action: str, claimant: str = "tab-a", queue_id: str = "default" +): + return client.post( + f"/api/v1/queue/{queue_id}/i/{item_id}/delivery", + json={"action": action, "claimant": claimant}, + headers=_auth(token), + ) + + +def test_the_owner_claims_confirms_and_releases( + queue_router: None, mock_sqlite_database: Database, mock_invoker: Invoker, client: TestClient, user1_token: str +): + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id) + + assert _deliver(client, user1_token, item_id, "claim").json() == {"status": "claimed", "retry_after_ms": None} + held = _deliver(client, user1_token, item_id, "claim", claimant="tab-b") + assert held.status_code == 200 + assert held.json()["status"] == "held" and 0 < held.json()["retry_after_ms"] <= 120_000 + assert _deliver(client, user1_token, item_id, "release").json()["status"] == "released" + assert _deliver(client, user1_token, item_id, "claim", claimant="tab-b").json()["status"] == "claimed" + assert _deliver(client, user1_token, item_id, "confirm", claimant="tab-c").json()["status"] == "delivered" + assert _deliver(client, user1_token, item_id, "claim").json()["status"] == "delivered" + + +def test_another_users_item_is_refused_and_an_admins_request_allowed( + queue_router: None, + mock_sqlite_database: Database, + mock_invoker: Invoker, + client: TestClient, + admin_token: str, + user1_token: str, + user2_token: str, +): + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id) + + for action in ("claim", "confirm", "release"): + assert _deliver(client, user2_token, item_id, action).status_code == 403 + assert _deliver(client, user1_token, item_id, "claim").json()["status"] == "claimed" + assert _deliver(client, admin_token, item_id, "claim", claimant="admin-tab").json()["status"] == "held" + + +def test_a_request_is_authorized_without_reading_the_items_session( + monkeypatch: pytest.MonkeyPatch, + queue_router: None, + mock_sqlite_database: Database, + mock_invoker: Invoker, + client: TestClient, + user1_token: str, + user2_token: str, +): + """Waiting browsers poll the route: it must not load and validate the whole item each time.""" + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id) + + def unread(self: SessionQueue, item_id: int) -> None: + raise AssertionError("the delivery route read the whole queue item") + + monkeypatch.setattr(SessionQueue, "get_queue_item", unread) + + assert _deliver(client, user1_token, item_id, "claim").json()["status"] == "claimed" + assert _deliver(client, user2_token, item_id, "claim").status_code == 403 + + +def test_a_missing_item_or_one_of_another_queue_is_not_found( + queue_router: None, mock_sqlite_database: Database, mock_invoker: Invoker, client: TestClient, user1_token: str +): + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id, queue_id="other") + + assert _deliver(client, user1_token, item_id + 1000, "claim").status_code == 404 + assert _deliver(client, user1_token, item_id, "claim").status_code == 404 + assert _deliver(client, user1_token, item_id, "claim", queue_id="other").status_code == 200 + + +@pytest.mark.parametrize( + "body", + [ + {"action": "steal", "claimant": "tab-a"}, + {"action": "claim", "claimant": ""}, + {"action": "claim", "claimant": "x" * 129}, + {"action": "claim"}, + ], +) +def test_a_malformed_request_is_rejected( + queue_router: None, + mock_sqlite_database: Database, + mock_invoker: Invoker, + client: TestClient, + user1_token: str, + body: dict[str, Any], +): + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id) + + response = client.post(f"/api/v1/queue/default/i/{item_id}/delivery", json=body, headers=_auth(user1_token)) + + assert response.status_code == 422 diff --git a/tests/app/services/session_queue/test_session_queue_delivery.py b/tests/app/services/session_queue/test_session_queue_delivery.py new file mode 100644 index 00000000000..0adacdb73f3 --- /dev/null +++ b/tests/app/services/session_queue/test_session_queue_delivery.py @@ -0,0 +1,169 @@ +"""Which browser stages a queue item's results: a claim per item that one claimant holds until it confirms the delivery, +releases the claim or lets it lapse, decided by one conditional statement on every backend.""" + +import json +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest +from pydantic_core import to_jsonable_python +from sqlalchemy import insert, select, update + +from invokeai.app.services.session_queue.session_queue_common import ( + QueueItemDeliveryResult, + QueueItemOwner, + SessionQueueItemNotFoundError, +) +from invokeai.app.services.session_queue.session_queue_default import QUEUE_ITEM_DELIVERY_CLAIM_TTL, SessionQueue +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.queries.session_queue import Delivery, SessionQueueQueries +from invokeai.app.services.shared.database.schema.session_queue import session_queue +from invokeai.app.services.shared.database.types import timestamp_text +from invokeai.app.services.shared.execution_state_migration import dump_execution_state +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from tests.fixtures.races import while_in_flight + +CLAIMED = QueueItemDeliveryResult(status="claimed") +DELIVERED = QueueItemDeliveryResult(status="delivered") +RELEASED = QueueItemDeliveryResult(status="released") + + +@pytest.fixture +def queue(database: Database) -> SessionQueue: + return SessionQueue(database) + + +@pytest.fixture +def item_id(database: Database) -> int: + session = GraphExecutionState(graph=Graph()) + values = { + "queue_id": "default", + "batch_id": "batch", + "session_id": session.id, + "session": json.dumps(dump_execution_state(session), default=to_jsonable_python), + "status": "completed", + "user_id": "system", + } + with database.begin(write=True) as conn: + return int(conn.execute(insert(session_queue).values(**values)).inserted_primary_key[0]) + + +def _delivery(database: Database, item_id: int) -> dict[str, Any]: + q = session_queue.c + columns = (q.delivery_claimant, q.delivery_claimed_at, q.delivered_at, q.updated_at) + with database.begin(write=False) as conn: + return conn.execute(select(*columns).where(q.item_id == item_id)).one()._asdict() + + +def _claimed_ago(database: Database, item_id: int, age: timedelta) -> None: + claimed_at = timestamp_text(datetime.now(timezone.utc) - age) + with database.begin(write=True) as conn: + conn.execute( + update(session_queue).where(session_queue.c.item_id == item_id).values(delivery_claimed_at=claimed_at) + ) + + +def test_a_claim_is_held_for_its_claimant_and_renewed_by_it(database: Database, queue: SessionQueue, item_id: int): + assert queue.claim_queue_item_delivery(item_id, "tab-a") == CLAIMED + _claimed_ago(database, item_id, timedelta(seconds=100)) + stale = _delivery(database, item_id) + + assert queue.claim_queue_item_delivery(item_id, "tab-a") == CLAIMED + + delivery = _delivery(database, item_id) + assert delivery["delivery_claimant"] == "tab-a" + assert delivery["delivery_claimed_at"] > stale["delivery_claimed_at"] + # Delivery is not a change of the item the queue's views show. + assert delivery["updated_at"] == stale["updated_at"] + + +def test_another_claimant_is_held_off_until_the_claim_lapses(database: Database, queue: SessionQueue, item_id: int): + queue.claim_queue_item_delivery(item_id, "tab-a") + _claimed_ago(database, item_id, QUEUE_ITEM_DELIVERY_CLAIM_TTL - timedelta(seconds=20)) + + held = queue.claim_queue_item_delivery(item_id, "tab-b") + + assert held.status == "held" + assert held.retry_after_ms is not None and 15_000 < held.retry_after_ms <= 20_000 + assert _delivery(database, item_id)["delivery_claimant"] == "tab-a" + + _claimed_ago(database, item_id, QUEUE_ITEM_DELIVERY_CLAIM_TTL + timedelta(seconds=1)) + assert queue.claim_queue_item_delivery(item_id, "tab-b") == CLAIMED + assert queue.claim_queue_item_delivery(item_id, "tab-a").status == "held" + + +def test_a_confirmed_delivery_is_final(database: Database, queue: SessionQueue, item_id: int): + queue.claim_queue_item_delivery(item_id, "tab-a") + + # Any claimant confirms: the results were delivered, whoever holds the claim. + assert queue.confirm_queue_item_delivery(item_id) == DELIVERED + delivered_at = _delivery(database, item_id)["delivered_at"] + assert delivered_at is not None + + _claimed_ago(database, item_id, QUEUE_ITEM_DELIVERY_CLAIM_TTL * 2) + assert queue.claim_queue_item_delivery(item_id, "tab-a") == DELIVERED + assert queue.claim_queue_item_delivery(item_id, "tab-b") == DELIVERED + assert queue.release_queue_item_delivery(item_id, "tab-a") == RELEASED + assert queue.confirm_queue_item_delivery(item_id) == DELIVERED + assert queue.claim_queue_item_delivery(item_id, "tab-b") == DELIVERED + assert _delivery(database, item_id)["delivered_at"] == delivered_at + + +def test_a_delivery_is_confirmed_without_a_claim(queue: SessionQueue, item_id: int): + assert queue.confirm_queue_item_delivery(item_id) == DELIVERED + assert queue.claim_queue_item_delivery(item_id, "tab-a") == DELIVERED + + +def test_only_its_claimant_releases_a_claim(database: Database, queue: SessionQueue, item_id: int): + queue.claim_queue_item_delivery(item_id, "tab-a") + + assert queue.release_queue_item_delivery(item_id, "tab-b") == RELEASED + assert queue.claim_queue_item_delivery(item_id, "tab-b").status == "held" + + assert queue.release_queue_item_delivery(item_id, "tab-a") == RELEASED + assert _delivery(database, item_id)["delivery_claimant"] is None + assert queue.release_queue_item_delivery(item_id, "tab-a") == RELEASED + assert queue.claim_queue_item_delivery(item_id, "tab-b") == CLAIMED + + +def test_a_claim_racing_another_waits_and_is_held_off(database: Database, queue: SessionQueue, item_id: int): + stale_before = timestamp_text(datetime.now(timezone.utc) - QUEUE_ITEM_DELIVERY_CLAIM_TTL) + results: list[QueueItemDeliveryResult] = [] + + errors = while_in_flight( + database, + lambda q: q.session_queue.claim_delivery( + item_id, "tab-a", now=timestamp_text(datetime.now(timezone.utc)), stale_before=stale_before + ), + lambda: results.append(queue.claim_queue_item_delivery(item_id, "tab-b")), + ) + + assert errors == [] + assert [result.status for result in results] == ["held"] + assert _delivery(database, item_id)["delivery_claimant"] == "tab-a" + + +def test_a_claim_released_before_its_refusal_is_explained_is_retried_at_once( + monkeypatch: pytest.MonkeyPatch, queue: SessionQueue, item_id: int +): + queue.claim_queue_item_delivery(item_id, "tab-a") + # tab-a releases between tab-b's refused claim and the read that explains the refusal. + monkeypatch.setattr(SessionQueueQueries, "delivery", lambda self, item_id: Delivery(None, None, None)) + + assert queue.claim_queue_item_delivery(item_id, "tab-b") == QueueItemDeliveryResult(status="held", retry_after_ms=1) + + +def test_an_items_owner_is_read_without_its_session(queue: SessionQueue, item_id: int): + assert queue.get_queue_item_owner(item_id) == QueueItemOwner(queue_id="default", user_id="system") + with pytest.raises(SessionQueueItemNotFoundError): + queue.get_queue_item_owner(item_id + 1000) + + +def test_a_missing_item_has_no_delivery(queue: SessionQueue, item_id: int): + missing = item_id + 1000 + with pytest.raises(SessionQueueItemNotFoundError): + queue.claim_queue_item_delivery(missing, "tab-a") + with pytest.raises(SessionQueueItemNotFoundError): + queue.confirm_queue_item_delivery(missing) + with pytest.raises(SessionQueueItemNotFoundError): + queue.release_queue_item_delivery(missing, "tab-a") diff --git a/tests/app/services/session_queue/test_session_queue_reads.py b/tests/app/services/session_queue/test_session_queue_reads.py index e1f26bd0cc6..3ee9b63d08f 100644 --- a/tests/app/services/session_queue/test_session_queue_reads.py +++ b/tests/app/services/session_queue/test_session_queue_reads.py @@ -75,13 +75,15 @@ def _item( @pytest.mark.usefixtures("accounts") class TestItems: - def test_an_item_carries_every_column_and_its_owners_name(self, database: Database) -> None: + def test_an_item_carries_every_column_but_its_delivery_and_its_owners_name(self, database: Database) -> None: item_id = _item(database, workflow='{"name": "w"}', origin="canvas", destination="gallery") item = database.queries.session_queue.item(item_id) assert item is not None - assert {column.name for column in session_queue.columns} <= item.keys() + delivery = {"delivery_claimant", "delivery_claimed_at", "delivered_at"} + columns = {column.name for column in session_queue.columns} - delivery + assert item.keys() == columns | {"user_display_name", "user_email"} assert (item["item_id"], item["user_display_name"], item["user_email"]) == ( item_id, "Alice", diff --git a/tests/app/services/session_queue/test_session_queue_workflow_call_retry.py b/tests/app/services/session_queue/test_session_queue_workflow_call_retry.py index 120287f5715..1b0120c48d1 100644 --- a/tests/app/services/session_queue/test_session_queue_workflow_call_retry.py +++ b/tests/app/services/session_queue/test_session_queue_workflow_call_retry.py @@ -6,6 +6,7 @@ from invokeai.app.services.events.events_common import QueueItemsRetriedEvent from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_common import RetriedQueueItem from invokeai.app.services.session_queue.session_queue_default import SessionQueue from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.graph import Graph, GraphExecutionState @@ -112,12 +113,32 @@ def test_retry_items_by_id_retries_root_once_for_child_chain_item( 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 + assert retry_result.retried_items == [ + RetriedQueueItem(source_item_id=root_item_id, item_id=retried_items[0].item_id) + ] retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)] assert len(retry_events) == 1 assert retry_events[0].retried_item_ids == [root_item_id] assert retry_events[0].user_ids == ["user-1"] assert retry_events[0].retried_item_ids_by_user == {"user-1": [root_item_id]} + assert retry_events[0].retried_items == retry_result.retried_items + + +def test_retrying_a_retry_pairs_it_with_its_own_rerun_and_keeps_the_original_lineage( + session_queue: SessionQueue, +) -> None: + original_item_id = _insert_queue_item( + session_queue, session=GraphExecutionState(graph=Graph()), user_id="user-1", status="failed" + ) + first = session_queue.retry_items_by_id("default", [original_item_id]).retried_items[0].item_id + session_queue.cancel_queue_item(first) + + second = session_queue.retry_items_by_id("default", [first]).retried_items + + assert [item.source_item_id for item in second] == [first] + rerun = session_queue.get_queue_item(second[0].item_id) + assert (rerun.status, rerun.retried_from_item_id) == ("pending", original_item_id) def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots( @@ -133,6 +154,15 @@ def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots( retry_result = session_queue.retry_items_by_id("default", [first_root_item_id, second_root_item_id]) assert retry_result.retried_item_ids == [first_root_item_id, second_root_item_id] + reruns = { + rerun.retried_from_item_id: rerun.item_id + for rerun in session_queue.list_all_queue_items("default") + if rerun.retried_from_item_id is not None + } + assert [(item.source_item_id, item.item_id) for item in retry_result.retried_items] == [ + (first_root_item_id, reruns[first_root_item_id]), + (second_root_item_id, reruns[second_root_item_id]), + ] retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)] assert len(retry_events) == 1 @@ -141,6 +171,7 @@ def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots( "user-1": [first_root_item_id], "user-2": [second_root_item_id], } + assert retry_events[0].retried_items == retry_result.retried_items def test_retried_items_inherit_the_project_of_the_root( diff --git a/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_13_add_session_queue_delivery.py b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_13_add_session_queue_delivery.py new file mode 100644 index 00000000000..6567b80f021 --- /dev/null +++ b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_13_add_session_queue_delivery.py @@ -0,0 +1,65 @@ +"""Queue items gain an empty delivery; existing items keep everything else.""" + +from logging import Logger +from pathlib import Path +from unittest.mock import MagicMock + +from sqlalchemy import delete, inspect + +from invokeai.app.services.config.config_default import DefaultInvokeAIAppConfig +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.schema.migrator import applied_migrations +from invokeai.app.services.shared.sqlite_migrator.migration_loader import MigrationBuildContext, build_migrations +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import MigrationBase +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import Migrator +from tests.fixtures.database import migrate_to_newest + +MIGRATION_ID = "2026_10_13_add_session_queue_delivery" +DELIVERY_COLUMNS = {"delivery_claimant", "delivery_claimed_at", "delivered_at"} + + +def _run(db: Database, migrations: list[MigrationBase]) -> bool: + migrator = Migrator(db) + for migration in migrations: + migrator.register_migration(migration) + return migrator.run_migrations() + + +def _columns(db: Database) -> set[str]: + with db.begin(write=False) as conn: + return {column["name"] for column in inspect(conn).get_columns("session_queue")} + + +def test_existing_items_gain_an_empty_delivery(tmp_path: Path) -> None: + logger = Logger("test") + config = DefaultInvokeAIAppConfig(use_memory_db=True, node_cache_size=0) + config._root = tmp_path + migrations = build_migrations(MigrationBuildContext(app_config=config, logger=logger, image_files=MagicMock())) + db = Database.open_sqlite(None, logger) + assert _run(db, [migration for migration in migrations if migration.id != MIGRATION_ID]) + assert not DELIVERY_COLUMNS & _columns(db) + with db.begin(write=True) as conn: + conn.exec_driver_sql( + "INSERT INTO session_queue (queue_id, batch_id, session_id, session, status, user_id) " + "VALUES ('default', 'batch', 'session', '{}', 'completed', 'system');" + ) + + assert _run(db, migrations) + + assert DELIVERY_COLUMNS <= _columns(db) + with db.begin(write=False) as conn: + row = conn.exec_driver_sql( + "SELECT session_id, status, delivery_claimant, delivery_claimed_at, delivered_at FROM session_queue;" + ).all() + assert row == [("session", "completed", None, None, None)] + db.dispose() + + +def test_running_again_after_an_interrupted_run_changes_nothing(database: Database, tmp_path: Path) -> None: + """On MySQL and MariaDB a migration that failed after its DDL runs again on columns it already added.""" + with database.begin(write=True) as conn: + conn.execute(delete(applied_migrations).where(applied_migrations.c.migration_id == MIGRATION_ID)) + + migrate_to_newest(database, tmp_path) + + assert DELIVERY_COLUMNS <= _columns(database)