diff --git a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx index 0c1feaa1881..f04e43b0807 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx @@ -22,14 +22,17 @@ Hover over a board and click its **⋮** button, or right-click it, to open its The board menu with Auto-add to this board, Download Board, Rename board, Archive Board and Delete Board. -* **Auto-add to this board** — makes this the board that receives new results, marked **Auto**, whichever board is selected. On the auto-add board, the item is **Stop auto-adding here**, which goes back to sending results to the selected board. Not offered for date boards or archived boards. See [Choosing where new media goes](/users-guide/gallery/introduction/#choosing-where-new-media-goes). +* **Auto-add to this board** — makes this the board that receives new results, marked **Auto**, whichever board is selected. On the auto-add board, the item is **Stop auto-adding here**, which goes back to sending results to the selected board. Not offered for date boards, archived boards or boards you can only view. See [Choosing where new media goes](/users-guide/gallery/introduction/#choosing-where-new-media-goes). * **Download Board** — downloads the board (see [below](#downloading-a-board)). * **Rename board** — opens a dialog to change the name. -* **Move to…** — moves the board to the **Library**, into the open project, or into one of your other projects. A board in a project is private to you, so this is offered only for your own private boards: a shared or public board stays in the **Library**, and an administrator cannot move another person's boards. You can also drag the board onto another group: the open project's, the **Library**, or one of your projects under **Other projects**. When the board's new project is hidden because **Other projects' boards** is off: if it was the selected board, the Gallery selects **Uncategorized**; if it was the auto-add board, results follow the selected board again. +* **Access** — on a multi-user installation, who else can open the board: **Restricted**, **Everyone can view** or **Everyone can contribute**. Only the board's owner and administrators see it. See [Image Boards](/configuration/multi-user-mode/user-guide/#image-boards). +* **Move to…** — moves one of your own boards to the **Library**, into the open project, or into one of your other projects; an administrator cannot move another person's boards. A board keeps its general access when it moves. You can also drag your board onto another group: the open project's, the **Library**, or one of your projects under **Other projects**. When the board's new project is hidden because **Other projects' boards** is off: if it was the selected board, the Gallery selects **Uncategorized**; if it was the auto-add board, results follow the selected board again. * **Archive Board** / **Unarchive Board** — see [below](#archiving-a-board). * **Delete Board** — see [below](#deleting-a-board). -A project's **Inbox** is managed through its project: its menu has **Export project (.invk)**, **Auto-add to this board** and **Download Board** instead, and renaming the project renames it. **Uncategorized** offers only **Auto-add to this board** and **Download Board**, and date boards have no menu. If the auto-add board is deleted, results go back to following the selected board. The same happens when you open a project after a board it used was deleted elsewhere — in another tab, or together with a project deleted from the Launchpad: the project stops selecting it, auto-adding to it and sending queued results to it. +On a board someone else owns, the menu starts with whose board it is and what you can do there, and it offers only what your role allows. A viewer can download the board; a contributor can also make it the auto-add board; an editor can also rename and archive it. Only the owner and administrators share or delete it, and only the owner moves it between the Library and their projects. + +A project's **Inbox** is managed through its project: its menu has **Export project (.invk)**, **Auto-add to this board** and **Download Board** instead, and renaming the project renames it. **Uncategorized** offers only **Auto-add to this board** and **Download Board**, and date boards have no menu. If the auto-add board is deleted or archived, results go back to following the selected board. The same happens when you open a project after a board it used was deleted elsewhere — in another tab, or together with a project deleted from the Launchpad — or after you lost access to it: the project stops selecting it, auto-adding to it and sending queued results to it. ### Archiving a board 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 ca640343b06..11759023558 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx @@ -1,7 +1,7 @@ --- title: Organizing your media description: Use the Gallery to organize images and videos into boards, choose where new results go, search by metadata or by content, upload media, and move, star, download or delete it. -lastUpdated: 2026-10-05 +lastUpdated: 2026-10-10 sidebar: order: 1 --- @@ -32,7 +32,7 @@ The Gallery has three parts: When the Gallery is narrow, or docked in a side rail, the board list sits above the media grid. When it is wide, the board list becomes a column on the left. Drag the divider between them to resize the list. -Each board shows its cover image, its name and a count such as **76 | 12**: 76 items on the Media tab and 12 on the Uploads tab. A project's Inbox is pinned at the top of its group with an inbox icon. On a multi-user installation, other people's boards show their owner's name. +Each board shows its cover image, its name and a count such as **76 | 12**: 76 items on the Media tab and 12 on the Uploads tab. A project's Inbox is pinned at the top of its group with an inbox icon. On a multi-user installation, boards other people let you open are listed under **Shared with me**, each with its owner and what you can do, such as **Ana · Can view**; administrators also see **Other users' boards**. ## Boards @@ -50,7 +50,8 @@ The new board is selected straight away, so it also becomes the destination for * A new project starts with its Inbox selected. See [Projects](/users-guide/projects/introduction/#where-your-images-go). * If you select a date board (see below), results go to the project's Inbox, because date boards are only a view. -* Uploads go to the selected board. +* If you select a board someone else shares with you that you can only view, results go to the project's Inbox, which is marked **Auto** while that board is selected; the same goes for an auto-add board you can no longer add to. +* Uploads go to the selected board; a board you can only view takes no uploads. * If you archive or delete the selected board, the selection, and with it the destination, moves to **Uncategorized**. To keep sending results to one board while you browse others, make it the **auto-add board**: choose **Auto-add to this board** from its menu. The board is marked **Auto**, and new generations, Canvas saves, pasted media and the results of workflow nodes whose **Board** is set to **Auto** all go there, whichever board you are viewing. Choose **Stop auto-adding here** from the same menu to go back to following the selected board. The setting behind this is **Send new results to the selected board**, in the [Gallery settings](/users-guide/gallery/gallery-reference/#gallery-settings). 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 721e6f544de..8f3082df598 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 @@ -167,6 +167,28 @@ invokeai-web A utility to migrate legacy "system"-owned assets to specific user accounts will be available in a future release. Until then, administrators can access and manage all legacy content. ::: +### Upgrading to 7.0 + +Upgrading converts board sharing and projects in place. The upgrade logs a warning for anything it +had to change beyond that: + +- **A project id held by more than one account.** Project ids are now unique across accounts. The + oldest project keeps its id; each other one gets a new id, and the log names the account's id and + the new project id. The project's document, boards, media, queue items and the account's open + projects follow it. A browser tab that was open on the project still knows it by its old id, so + the edits it had not yet synced come back as a project-deleted conflict: export them first if they + are wanted, then open the project again from the Launchpad. +- **Boards in a project that no longer exists.** A board can name a project its owner no longer has, + for example a board of an account deleted before the upgrade. The upgrade moves such boards out of + the project and logs how many there were. + +Board visibility becomes general access: private boards are **Restricted**, shared boards **Everyone +can view**, and public boards **Everyone can contribute**, so every user can add media to them and +remove only what they added. In the Gallery, an administrator sees boards other users have not +opened to them under **Other users' boards**, closed until opened, and can manage them from their +menus as before, +except that only a board's owner can move it between the Library and their projects. + ## User Management ### Creating Users @@ -220,8 +242,9 @@ If no arguments are provided, the script will run in interactive mode. ``` :::danger[Data Loss] -Deleting a user removes the user record and cascades to their sessions, board shares, sent -invitations, and per-user client state. It does **not** delete the boards, images, videos, +Deleting a user removes the user record and cascades to their sessions, board memberships, sent +invitations, projects, and per-user client state. Their boards leave the deleted projects and stay +in no project. It does **not** delete the boards, images, videos, workflows, queue items, or style presets they created — those rows remain in the database, owned by a user_id that no longer exists, and will not appear in other (non-admin) users' galleries. Administrators keep gallery visibility of the orphaned images and videos and can review or delete diff --git a/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx index 2a33baba35e..3fd29162054 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx @@ -528,7 +528,7 @@ Resources are now filtered by the authenticated user: GET /api/v1/boards/?all=true # Returns all boards # After (multi-user) -GET /api/v1/boards/?all=true # Returns boards the current user can access, including their own boards plus shared/public boards; admins can see all boards +GET /api/v1/boards/?all=true # Returns boards the current user can access, including their own boards, boards they are a member of and boards with general access; admins can see all boards ``` **Images:** @@ -551,6 +551,9 @@ GET /api/v1/workflows/ # Regular users see their own queue items in full and may see redacted details for other users' items on queue-status endpoints GET /api/v1/queue/... # Queue data, sanitized for non-admin viewers +# With origin_prefix, item_ids, current, next and status cover only the caller's own items +GET /api/v1/queue/{queue_id}/status?origin_prefix=webv2:p::q: + # Administrators see all queue items in full GET /api/v1/queue/... # Full queue data ``` @@ -838,6 +841,10 @@ On an error, it returns `422 Unprocessable Content` and the following JSON: } ``` +### Board Endpoints + +Boards are available to every authenticated user; what each caller may do to one is decided by their role on it. + #### List Image Boards **Endpoint:** `GET /api/v1/boards/` @@ -859,7 +866,11 @@ On an error, it returns `422 Unprocessable Content` and the following JSON: "deleted_at": "2026-05-07T03:04:00.738Z", "cover_image_name": "string", "archived": false, + "general_access": "restricted", "board_visibility": "private", + "my_role": "owner", + "allowed_actions": ["read", "add_media", "manage_items", "rename", "set_cover", "archive", "set_general_access", "move", "delete"], + "admin_override": false, "image_count": 0, "asset_count": 0, "owner_username": "string" @@ -869,11 +880,20 @@ On an error, it returns `422 Unprocessable Content` and the following JSON: ``` This returns a paged response. See the swagger page (`http://localhost:9090/docs#/boards/list_boards`) for details. -The `board_visibility` field will be one of: +Each board describes the caller's access to it: + +- `general_access` -- the role every other user gets: `restricted` (none), `viewer` or `contributor`. Only the owner or an administrator may change it, with `PATCH /api/v1/boards/{board_id}`. +- `my_role` -- the caller's own role: `viewer`, `contributor`, `editor` or `owner`; `null` when only administrator rights give access. +- `allowed_actions` -- the board actions the caller may take. Clients should decide what to offer from this list rather than from `my_role`. +- `admin_override` -- `true` when administrator rights give the caller more than `my_role` does. +- `owner_username` -- the owner's display name. Administrators see the email of an owner without one; other users see `Unknown user`. +- `project_id` -- shown only to the board's owner and administrators. An image or video DTO's `project_id` likewise names the project it was made in only to that project's owner and to administrators; everyone else reads `null`. -- `private` -- private to the owner and administrator -- `shared` -- read/write to the owner and administrator, read-only to everyone else -- `public` -- read/write by everyone +Only a board that does not exist answers `404`. A board the caller cannot open answers `403` with the detail `{"code": "board_unreadable", "message": "Not authorized to access this board"}`, and an action the caller's role does not allow answers `403` with the code `board_action_denied` and the message `Not authorized to change this board`: a refusal can be reversed by the board's owner, so clients must not treat it as the board being gone, and the code tells lost access from a stale role without matching the message. A project's inbox refuses renaming, archiving, moving, deleting and a change of general access with `409`; those follow its project. + +:::caution[Deprecated] +`board_visibility` is `general_access` under its pre-7.0 name (`private` = `restricted`, `shared` = `viewer`, `public` = `contributor`). It is still returned and accepted on update, but sending it together with `general_access` is refused with `422`. It will be removed in 8.0. +::: #### Get One Board @@ -891,13 +911,35 @@ The `board_visibility` field will be one of: "deleted_at": null, "cover_image_name": "08689e4b-f084-4c49-83a8-4fc1edb167c4.png", "archived": false, + "general_access": "viewer", "board_visibility": "shared", + "my_role": "viewer", + "allowed_actions": ["read"], + "admin_override": false, "image_count": 55, "asset_count": 0, - "owner_username": null + "owner_username": "Sam" } ``` +### Project Endpoints + +Projects are private to the account that created them. The full save protocol, including the content and workspace halves and the schema floors, is described in [Project Files (.invk)](/development/front-end/project-files/). + +#### Create a Project + +**Endpoint:** `POST /api/v1/projects` + +Creates the project and its inbox board and answers `201` with the record and `workspace`. The optional `workspace` field is the caller's first workspace in the project, the workspace document itself (a create's workspace is always the first row, so it carries no `expected_revision`), stored in the same transaction as the content, so no project is created without its creator's workspace. It is checked as on `PUT /api/v1/projects/{project_id}/workspace`, except that the create's `max_canvas_schema_version` covers it, and it needs `minimum_document_schema_version` at 4 or above: beside a whole document it is refused with `400` and the code `workspace_needs_split_document`, and nothing is created. The response's `workspace` is the stored workspace (`data`, `revision` 1, `updated_at`), or `null` when the request carried none. + +#### Save a Project + +**Endpoint:** `PUT /api/v1/projects/{project_id}` + +Saves the project's content and answers with the record and `workspace`. The optional `workspace` field (`{data, expected_revision}`) stores the caller's workspace in the same transaction and is for the save that splits a whole project (document schema 3 or older) into content and workspace: the raised document floor, the content and the first workspace row then land together or not at all. Its revision is checked as on `PUT /api/v1/projects/{project_id}/workspace`, and a mismatch refuses the whole save with `409` and the workspace's `current_revision`. Omit it on every other save: a `workspace` sent with a save that leaves the document floor below 4 is refused with `400` and the code `workspace_needs_split_document`. The split cannot go without it either: a save that raises the floor from below 4 to 4 or above without `workspace` is refused with `400` and the code `split_needs_workspace`, so the split of a whole project never lands without the caller's workspace. + +Workspace saves declare `max_canvas_schema_version` (default 2) as well as `max_document_schema_version`; a client below the project's canvas floor gets `412` with the code `canvas_schema_unsupported`. + ## Best Practices diff --git a/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx index 8e96cd8465d..f40b1a6b810 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx @@ -118,7 +118,7 @@ As a regular user, you can: - View redacted information for jobs being run by other users - Customize your UI preferences (theme, hotkeys, etc.) - View available models (read-only access to Model Manager) -- View shared and public boards created by other users +- Open boards other users have given them access to, and add to the ones they can contribute to - View and use workflows marked as shared by other users You cannot: @@ -154,15 +154,34 @@ Administrators have all regular user capabilities, plus: ### Image Boards -In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards have three visibility levels: +In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit, in the Library or inside a project. What another user may do with your board depends on their **role** on it: -- **Private** (default): Only you (and administrators) can see and modify the board. -- **Shared**: All users can view the board and its contents, but only you (and administrators) can modify it (rename, archive, delete, or add/remove images). -- **Public**: All users can view the board. Only you (and administrators) can modify the board's structure (rename, archive, delete). +| Role | Can | +| --- | --- | +| **Viewer** | Browse the board, open and download its images and videos, copy them to their own boards, and use them in their own generations. | +| **Contributor** | Everything a viewer can, and add their own images and videos to the board. They can remove, star or delete only what they added. | +| **Editor** | Everything a contributor can, and remove or star anyone's items, rename the board, set its cover and archive it. | +| **Owner** | Everything, including changing who has access, moving the board between the Library and a project, and deleting it. | -To change a board's visibility, right-click on the board and select the desired visibility option. +A board's **general access** sets the role every other user gets: -Administrators can see and manage all users' image boards and their contents regardless of visibility settings. +- **Restricted** (default): no one else can open the board. +- **Everyone can view**: every user is a viewer. +- **Everyone can contribute**: every user is a contributor. + +This release does not yet let an owner give a role to one user at a time, so general access is how other users get access to a board. To change it, open the board's menu (**⋮** on the board, or right-click it) and choose **Access**, then **Restricted**, **Everyone can view** or **Everyone can contribute**. The change applies at once. + +Boards other users let you open appear in the Gallery's board list under **Shared with me**, each with its owner and what you can do, for example **Ana · Can view**. Searching the board list also finds them by their owner's name. A shared board's menu starts with whose board it is and what you can do there, and it offers only the actions your role allows: on a board you can only view, you can browse and download, and the menu says whom to ask for contributor access. + +You cannot add media to a board you can only view. It is not offered when moving media, dropping media on it does nothing, and uploading to it is refused. If it is the selected board (or the auto-add board), new results go to your project's **Inbox** instead, which is marked **Auto**. + +If your access to a board changes while you have Invoke open, the board list catches up when it next refreshes: a board you can no longer open leaves it, and new results stop going to it. A change you make from the board's menu in the meantime is refused, and a notice says your access changed. Moving or uploading media to it is refused with the server's reason. + +Only the person who added an image or video, or an administrator, can delete it permanently. When an owner or editor removes someone else's item from a board, it returns to that person's **Uncategorized** board. Deleting a board together with its media likewise deletes only your own media, whether or not you are an administrator; other people's items return to their Uncategorized boards. Only an administrator deleting another user's board deletes everything on it. + +A project's **Inbox** stays restricted; its access follows the project. Other boards inside a project can have any general access, and they keep it when they move between the Library and a project. + +A board you cannot open is refused, not reported as missing, so a board whose access you lose and later regain comes back as it was. Administrators can open and manage every user's boards; a board's owner and administrators are the only ones who can change its general access. Administrators find other users' boards under **Other users' boards**, after **Shared with me**, closed until they open it; a board another user shares with everyone appears under **Shared with me** instead. ### Going From Multi-User to Single-User Mode @@ -306,7 +325,7 @@ These settings are stored per-user and won't affect other users. ### Can other users see my images? -Not unless you change your board's visibility to "shared" or "public". All personal boards and images are private by default. +Not unless you give them access. Boards start **restricted**: only you and administrators can open them. A board set to **Everyone can view** or **Everyone can contribute** is open to every user. ### Can I share my workflows with others? diff --git a/docs/src/content/docs/development/Front End/canvas-projects.mdx b/docs/src/content/docs/development/Front End/canvas-projects.mdx index dde843684d9..e76dc2fdeed 100644 --- a/docs/src/content/docs/development/Front End/canvas-projects.mdx +++ b/docs/src/content/docs/development/Front End/canvas-projects.mdx @@ -5,7 +5,7 @@ title: Canvas Projects Canvas projects serialize the current canvas into a portable `.invk` archive. The feature lives in `invokeai/frontend/webv1/src/features/controlLayers/` and is exposed in the canvas toolbar archive menu and the canvas context menu under **Project**. :::note -This page describes **version 1** of the `.invk` container, which is canvas-only and written by this frontend. Workbench projects write version 2 of the same container — see [Project Files (.invk)](/development/front-end/project-files/). The two readers each pin their own version and refuse the other's archives. +This page describes **version 1** of the `.invk` container, which is canvas-only and written by this frontend. Workbench projects write version 3 (earlier, 2) of the same container — see [Project Files (.invk)](/development/front-end/project-files/). The two readers each pin their own version and refuse the other's archives. ::: ## File format diff --git a/docs/src/content/docs/development/Front End/project-files.mdx b/docs/src/content/docs/development/Front End/project-files.mdx index 872bbf78f4f..664b00d8c01 100644 --- a/docs/src/content/docs/development/Front End/project-files.mdx +++ b/docs/src/content/docs/development/Front End/project-files.mdx @@ -6,11 +6,15 @@ Workbench projects export to a portable `.invk` archive. The feature lives in `i Import also accepts the `.invokeproject.json` envelope shipped by earlier webv2 builds. New exports use `.invk`; the JSON path remains read-only compatibility. -`.invk` is shared with the previous frontend's [canvas projects](/development/front-end/canvas-projects/), which write version 1 of the same container. Workbench projects write version 2. The two are deliberately not interchangeable — see [Versions](#versions). +`.invk` is shared with the previous frontend's [canvas projects](/development/front-end/canvas-projects/), which write version 1 of the same container. Workbench projects write version 3, and still read version 2. The two are deliberately not interchangeable — see [Versions](#versions). + +## Project ids + +A project id is global: no two accounts hold the same one. Every client path mints `project-` (drafts, imports and duplicates alike; conflict copies derive theirs from the source project's id), and a create naming an id another account already holds is refused with `409`, like a create that already happened. A lookup tells the two apart: the caller's own project answers, another account's answers `404`, as every read or save of a project the caller does not own does (deleting one is a no-op). Queue items record their project in their origin (`webv2:p::q:`), and a non-admin's origin-scoped queue reads return only their own items. ## Boards belong to projects -A board lives in exactly one of its owner's projects, or in the **Library** — the tier every project can see. `boards.project_id` is the stored membership (`NULL` is the Library); it is a plain indexed column rather than a foreign key, because `projects` is keyed by `(user_id, project_id)` and adding a composite constraint would mean rebuilding `boards` under enforced cascades. The services keep it consistent: `POST /boards/?project_id=` inserts from the project row itself, `PATCH /boards/{id}` with `project_id` (a project, or `null` for the Library) moves a board and guards the destination's existence in the same `UPDATE`, and both answer `404` for a project the owner does not have. Boards in a project are private and unshared; a move or visibility change that would make a member otherwise is refused with `409`. +A board lives in exactly one of its owner's projects, or in the **Library** — the tier every project can see. `boards.project_id` is the stored membership (`NULL` is the Library); it is a plain indexed column rather than a foreign key, because `projects.board_id` already references `boards`, and two tables that reference each other have no order to be created or copied in. Deleting an account clears its boards' memberships in the same transaction. The services keep it consistent: `POST /boards/?project_id=` inserts from the project row itself, `PATCH /boards/{id}` with `project_id` (a project, or `null` for the Library) moves a board and guards the destination's existence in the same `UPDATE`, and both answer `404` for a project the owner does not have. A board keeps its general access and memberships when it moves between the Library and a project; only the inbox's access follows its project. Every project also has an **inbox**: the one board `projects.board_id` names (`NOT NULL UNIQUE`, foreign key `ON DELETE RESTRICT`). It is created with the project, is always a member of it, and is the only board the project APIs manage. @@ -62,9 +66,24 @@ client from opening a v3 project: it is treated as v2, not as unrestricted. The forward-version gate and the project API adapter consume the same constant so advertised support cannot drift from actual parsing support. +## Project content and workspaces + +From document schema 4 a project is two documents. Its **content** is what the project is: the shared work a collaborator would review, export or edit. Each member's **workspace** is how they work on it: layout, widgets and their generation settings, and their selection and staging. The content stays in `projects.data`; each workspace is a row of `project_workspaces`, keyed by project and account, which deleting either removes. In 7.0 only a project's owner can save a workspace in it (anyone else gets `404`). The web client keeps one whole document per project and splits it at the server boundary (see `src/workbench/projects/README.md`). The save that splits a whole project sends both halves in one request, so the raised floor, the content and the member's first workspace row commit together or not at all; a split that landed its content alone would join every later load with a default workspace, and the member's settings would be gone. From then on each half saves on its own. Imports, duplicates and conflict copies send the creator's workspace with the create, which stores both in one transaction, so no project lands without its creator's workspace. A project whose workflow repeats an item id, which only a build before the split could write, is created and kept whole, since split content that repeats one is refused on load. + +- `POST /projects` creates the content and, when the request carries `workspace` (the workspace document itself; no revision, since it is always the first), the creator's workspace in the same transaction, and answers with the record and `workspace` (`null` when none was sent). A `workspace` with a document floor below 4 is refused with `400` and the code `workspace_needs_split_document`, and nothing is created. +- `GET /projects/{id}` returns the content and, as `workspace`, the caller's workspace with its own `revision` (or `null` before they have saved one). +- `PUT /projects/{id}` saves the content, revision-checked as before, and answers with the record and `workspace`: `null`, unless the request carried `workspace` (`{data, expected_revision}`), which it stores in the same transaction and answers with. That field is for the split only, and the split needs it: its revision is checked as on the workspace route, and a mismatch refuses the whole save, content included, with the same `409`; a save that raises the document floor from below 4 to 4 or above without it is refused with `400` and the code `split_needs_workspace`. +- `PUT /projects/{id}/workspace` saves the caller's workspace against its own `expected_revision` (`null` for the first save); a mismatch is `409` with the `current_revision`. The two halves never conflict with each other. +- Both halves have the 32 MiB limit, and both pass through the project write limiter, which runs one write per account at a time, so a client saves the halves one after the other. +- The listing gives each project's `workspace_updated_at` for the caller beside the content's `updated_at`. + +Each half indexes the media it names separately (`project` and `project_workspace` references), so saving one never drops what only the other names, such as a staged candidate the intermediates cleanup would otherwise delete. + +`minimum_document_schema_version` is a second floor beside the canvas one, with the same rules: reads declare `max_document_schema_version` as a query parameter and content saves in their body (both default to 3; workspace saves must declare it, and declare `max_canvas_schema_version` too, since a workspace holds canvas state and the canvas floor applies to it), a save may raise the floor but not above its own maximum and never lower it (`400` with the code `document_schema_downgrade`), and a client below it gets `412` with the code `document_schema_unsupported`. Projects saved before the split are at 3; the first save that splits one raises it to 4, so a client that predates the split, which would read the content as a whole document and save it back without the workspace, is refused instead. + ## Custom fonts -Webv2 exports font dependencies in the manifest's optional `fonts` array. Each dependency records a SHA-256 content hash, readable family and face labels, source reference IDs, and an optional `fonts/.` entry. The Canvas schema compatibility floor protects the newer editable text fields; the ZIP manifest remains version 2. +Webv2 exports font dependencies in the manifest's optional `fonts` array. Each dependency records a SHA-256 content hash, readable family and face labels, source reference IDs, and an optional `fonts/.` entry. The Canvas schema compatibility floor protects the newer editable text fields. Fonts are matched against the canvas of each half the archive carries, the workspace's staging area included. **Include font files** is unchecked by default. When selected, export authenticates and verifies every required font download, bundles each original file once, and fails if a requested font is missing or changed. Variable fonts travel as their original files, preserving editable axes. Export does not infer redistribution rights from metadata. @@ -72,12 +91,13 @@ Import verifies dependency paths and hashes before any mutation, preflights font ## File format -`.invk` files are ZIP archives. The current manifest version is `2`. +`.invk` files are ZIP archives. The current manifest version is `3`; version `2` archives still import, and a version-1 manifest is refused by name. | Target | Required | Description | | --------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `manifest.json` | yes | Archive version, app version, creation timestamp, project name, and optionally the source project id and the cover entry's path. | -| `project.json` | yes | The whole project document — the same payload the backend stores as the project record's opaque `data`. | +| `manifest.json` | yes | Archive version, app version, creation timestamp, project name, `documentSchemaVersion` (v3), and optionally the canvas floor, the source project id and the cover entry's path. | +| `project.json` | yes | The project content (document schema 4) — the same payload the backend stores as the project record's `data`. In a v2 archive, the whole document. | +| `workspace.json` | no | The exporter's workspace (v3 only): layout, widgets and their generation settings, prompt history, selection and staging. | | `board.json` | yes\* | Every board of the project as the exporting server enumerated them: `{version: 2, boards: [{name, isInbox, archived, items: [{kind, category, name, starred}]}]}`, the inbox first. Version 1 (the inbox alone, as `{version: 1, items}`) is still read. | | `cover.` | no | Thumbnail of the project's newest result. The extension varies by what the server served, so the entry path is recorded in the manifest. | | `images/` | no | Image bytes, keyed by the exact `image_name` the exporting server used. | @@ -85,6 +105,12 @@ Import verifies dependency paths and hashes before any mutation, preflights font \* Every archive this app writes carries `board.json`. A reader still treats it as optional, because dev builds from before project boards wrote archives without it; its absence means "this file names no board", which is different from a board that was empty. +**The exporter's workspace is optional.** The export dialog's **Include my settings for this project** is on by default and remembered per account (`exportIncludesWorkspace`); off, the file shares the project without anyone's prompts or setup. Only what the file carries is bundled: media that only a left-out workspace names, such as a staged candidate, stays behind, and the cover then comes from the canvas, since the gallery's newest result belongs to the workspace. A closed project is exported with the caller's stored workspace, or none if they have never saved one in it. Never another member's: a workspace is only ever the caller's own. + +**Version 3 and the document schema.** `documentSchemaVersion` names the schema `project.json` is written in: 4 for content. A project this build cannot read is exported verbatim for recovery, in whatever schema the server holds it, so the field can be lower or higher. Held as content (schema 4 or later), its workspace travels or stays out as chosen; held whole, its workspace cannot be taken out, so leaving it out is refused with a message that says so. Import refuses a `documentSchemaVersion` newer than 4, and a `minimumCanvasSchemaVersion` newer than the canvas maximum, as soon as it reads the manifest, before it parses any other entry. Both tell people the file was written by a newer version of Invoke; the error's message, for logs and details, names which schema was too new. Builds that predate version 3 refuse these archives as an unsupported version, and 7.0 never writes version 2. + +Import joins content with `workspace.json`, which becomes the importer's own workspace (installation state stripped, as for the document). Without that entry it uses the workspace a new project starts with. The project is created as content, then that workspace is saved beside it, opening on the inbox the server claimed. Like board placement, that save is never fatal but is reported: the import or duplicate finishes with a warning that the project opens with a new project's layout and settings. A whole document (a v2 archive, or a legacy JSON envelope) is canonicalized the same way and created split. Fields of neither half do not survive, as they would not survive the editor's first save. + Assets are filed by kind rather than pooled. Images and videos are separate backend namespaces with separate fetch and upload routes, so import has to know which an entry is before it can restore it — the folder states that, where a shared one would leave it to be guessed from a file extension. Adding `videos/` needed no version bump: `readInvkArchive` ignores entries it does not recognize, so a reader predating it reads such an archive and simply leaves those references dangling, exactly as it would for an absent image. The manifest is written indented and the document compact: the manifest is the one entry someone might open by hand, and the document is the largest thing in the archive before compression. Text entries are deflated; image entries are stored, since PNG and WEBP are already compressed. @@ -187,7 +213,7 @@ On import the cover prefers an image the restore already put on this server — Adding `board.json` did **not** bump the manifest version. A version tells a reader what it may assume about a file *somebody else wrote*, and webv2 has not shipped — no archive exists anywhere that predates the entry except the ones dev builds produced, and those are read exactly as what they are: an archive that names no board, whose import restores document references and lets the server create the new project an empty board. Inventing an empty board for such a file would claim knowledge it does not contain, which is why "absent" and "empty" stay distinguishable in the reader. -The v2 manifest may carry `minimumCanvasSchemaVersion`. This is the source project's monotonic compatibility floor, not merely the version of its live canvas. It may conservatively retain a higher requirement inherited from a legacy server record whose queue history is removed during canonical export. Export copies the acknowledged server floor, and import creates the new project with the greater of that floor and the requirement derived from the canonical imported document. An archive above this client's maximum is refused before a staging board or any media upload exists. Older v2 archives omit the field and continue to derive their requirement from their document. +The v2 and v3 manifests may carry `minimumCanvasSchemaVersion`. This is the source project's monotonic compatibility floor, not merely the version of its live canvas. It may conservatively retain a higher requirement inherited from a legacy server record whose queue history is removed during canonical export. Export copies the acknowledged server floor, and import creates the new project with the greater of that floor and the requirement derived from the canonical imported document. An archive above this client's maximum is refused as soon as its manifest is read, before any other entry is parsed and before a staging board or any media upload exists. Older v2 archives omit the field and continue to derive their requirement from their document. A malformed `board.json` is a different matter and is refused as `damaged`: half an enumeration is not a board. The entry carries its own `version: 1`, which is the *file's* version and not the API's — the snapshot endpoint returns `{items}` and nothing else, so neither format is pinned to the other. @@ -195,7 +221,7 @@ Manifests parse through a zod discriminated union on `version`, so a version 1 a The legacy reader is the mirror image: it pins `version: 1` and refuses everything this app writes. -Future format changes should add a new member to the union rather than widening version 2, so that every version this app has ever written can be named precisely when refused. +Version 3 is such a member: it carries `documentSchemaVersion` and the optional `workspace.json`, and version 2 stays readable as it was. Future format changes should add another member rather than widen an existing one, so that every version this app has ever written can be named precisely when refused. ## Guards diff --git a/docs/src/content/docs/development/Guides/database-layer.mdx b/docs/src/content/docs/development/Guides/database-layer.mdx index 2e2115bc397..74637908e3c 100644 --- a/docs/src/content/docs/development/Guides/database-layer.mdx +++ b/docs/src/content/docs/development/Guides/database-layer.mdx @@ -43,6 +43,10 @@ What backends spell differently comes from `dialect.py`: - `JsonString(column, path)` is the string at a member path of the JSON document in a text column, and NULL when the document is not valid JSON or the member is missing or holds anything else. SQLite's `json_extract()` alone would give 1 for a member holding true, where a server gives 'true', and raise on a document that is not JSON. - `OrderedJoin(left, right, onclause)` is an inner join, and only that, whose left side SQLite plans as the outer loop: it renders as `CROSS JOIN ... ON` there, which SQLite's planner never reorders, and as a plain `JOIN` elsewhere. For a join that must stay proportional to its left side, such as a board's membership rather than every image an index offers first. +### Board access + +Who may read or change a board is decided in one place on each side of the layer. In SQL, `queries/board_access.py` holds `readable_board`, the condition every listing of boards or of media through boards applies for an account that is not an administrator. In Python, the `board_access` service (`services/board_access/`) decides single boards and items: `MINIMUM_ROLE` in `board_access_common.py` is the one table of the least role each board action needs, and routers, graph execution, workflow-call expansion and socket routing all ask the service rather than reading `general_access` or `board_members` themselves. A single board's role facts come from `q.boards.access_facts()`, and the caller's roles across a page of listed boards from `q.boards.member_roles()`. A new rule changes both sides, and `tests/app/services/board_access/` holds the role matrix and a test that the board listing shows exactly the boards the service lets the caller read. + ## Schema The tables are SQLAlchemy `Table`s in `schema/`, one module per domain, all in `schema.metadata`. They are the one definition of the schema: query modules build their statements from them, and a server database is created from them. @@ -150,7 +154,7 @@ Image moves take no database lock. Their jobs are created, worked and committed The font catalog takes none either, for the same reason. An upload checks for the same content and for the quota, writes its file and inserts its row under the font service's managed-storage lock, which the sweep for orphaned files takes too; the file is written outside any transaction. Directory scans run one at a time under a lock of their own, so two cannot insert the same new font. -A decision about one row needs no named lock: the row is the lock. The transaction locks the row before it reads what it decides on, with a query method marked `@locking` that selects it `FOR UPDATE` (`q.boards.lock(board_id)`, `q.projects.lock(user_id, project_id)`). Every transaction that changes what the decision reads writes or locks the same row, so it waits until the decision has committed, and the decision sees what such a transaction committed before. A project save works this way, so its revision and compatibility checks apply to the row it writes, and so does a project claiming a board: a board made public, deleted or claimed by another project meanwhile is either seen by the claim or finds the board claimed. Row locks, too, are held only inside a `transaction()`; called on its own, a lock method raises. +A decision about one row needs no named lock: the row is the lock. The transaction locks the row before it reads what it decides on, with a query method marked `@locking` that selects it `FOR UPDATE` (`q.boards.lock(board_id)`, `q.projects.lock(user_id, project_id)`). Every transaction that changes what the decision reads writes or locks the same row, so it waits until the decision has committed, and the decision sees what such a transaction committed before. A project save works this way, so its revision and compatibility checks apply to the row it writes, and so does a project claiming a board: a board opened to other users, deleted or claimed by another project meanwhile is either seen by the claim or finds the board claimed. Row locks, too, are held only inside a `transaction()`; called on its own, a lock method raises. Rows are locked in one order, a project's before its board's, so two transactions that lock both cannot deadlock on them. A single statement that locks a board and then reads its project, like the boards service's guarded update and delete, cannot keep that order; the rare deadlock it causes with a project write is retried by `run()`. diff --git a/docs/src/content/docs/troubleshooting/known-bugs.mdx b/docs/src/content/docs/troubleshooting/known-bugs.mdx index 3de5beb38fd..eef59317ace 100644 --- a/docs/src/content/docs/troubleshooting/known-bugs.mdx +++ b/docs/src/content/docs/troubleshooting/known-bugs.mdx @@ -1,20 +1,20 @@ --- title: Known Bugs description: Places where the current release does not yet behave as the documentation describes, with workarounds. -lastUpdated: 2026-10-07 +lastUpdated: 2026-10-10 --- This page lists known cases where InvokeAI does not yet behave as this documentation describes, or no longer behaves as v6 did. Each entry explains what happens instead and how to work around it. Entries are removed once the fix is released. ## v7.0.0-alpha -### Multi-user mode: boards and workflows cannot be shared +### Multi-user mode: workflows cannot be shared -**Documented behavior:** In multi-user mode, a board's owner can set it to **Shared** or **Public** so that other users can view it, and a workflow's owner can mark it as shared so that it appears in every user's workflow library. See [Working with Your Content in Multi-User Mode](/configuration/multi-user-mode/user-guide/#working-with-your-content-in-multi-user-mode). +**Documented behavior:** In multi-user mode, a workflow's owner can mark it as shared so that it appears in every user's workflow library. See [Working with Your Content in Multi-User Mode](/configuration/multi-user-mode/user-guide/#working-with-your-content-in-multi-user-mode). -**Current behavior:** v7 has no controls for changing a board's visibility or sharing a workflow. Boards and workflows you create stay private: only you and administrators can see them. +**Current behavior:** v7 has no control for sharing a workflow. Workflows you create stay private: only you and administrators can see them. Boards can be shared from their menu's **Access** item. -**Workaround:** None in the v7 interface. Administrators can still view and manage all users' boards, images, and workflows. +**Workaround:** None in the v7 interface. Administrators can still view and manage all users' workflows. **Tracking:** [invoke-ai/InvokeAI#9673](https://github.com/invoke-ai/InvokeAI/issues/9673) diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py index 79a0cc1855f..47eb82069a0 100644 --- a/invokeai/app/api/routers/_access.py +++ b/invokeai/app/api/routers/_access.py @@ -4,77 +4,75 @@ specifics so any route can call them after resolving `current_user`. """ +from collections.abc import Iterable +from typing import Optional, Protocol + from fastapi import HTTPException from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.board_records.board_records_common import ( - BoardRecord, - BoardRecordNotFoundException, - BoardVisibility, +from invokeai.app.services.board_access.board_access_common import ( + BoardAccess, + BoardAction, + ItemAccessDeniedError, + ItemAction, ) +from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException from invokeai.app.services.project_records.project_records_common import ProjectRecordNotFoundError -def _get_board_record(board_id: str) -> BoardRecord: - """Get a board record, translating only a confirmed missing board to 404.""" - try: - return ApiDependencies.invoker.services.board_records.get(board_id) - except BoardRecordNotFoundException: - raise HTTPException(status_code=404, detail="Board not found") - - -def _board_grants_contribution(board_id: str, current_user: CurrentUserOrDefault) -> bool: - """Whether this user may put media on the board: they own it, or it is Public. +def _require( + board_id: str, current_user: CurrentUserOrDefault, actions: Iterable[BoardAction], inbox_left_to_storage: bool +) -> BoardAccess: + """The caller's access to the board, when it allows every action; raises otherwise. - Shared boards are deliberately excluded — `Shared` grants visibility, not contribution. - Storage errors propagate so a batch cannot silently turn an undecidable permission check into - an ordinary denial. + Only a missing board answers 404. A board the caller may not read answers 403, like an image or a video they + may not read (see `_assert_image_record_exists`), because the two refusals mean opposite things to a client: + gone is permanent, and the client drops its selection, auto-add target and cached items for the board; denied + is a permission decision an owner can reverse by flipping the board back to shared or re-granting membership, + and nothing would restore what the client dropped over it. Board ids are generated UUIDs, so learning that + one exists buys an attacker nothing they could enumerate. A board the caller can read but not change + answers 403 too, worded as the change refused. Each 403 carries a `code` beside its message: a client that + lost access drops nothing, while one whose role is stale only refreshes the board. """ try: - board = ApiDependencies.invoker.services.board_records.get(board_id) - except BoardRecordNotFoundException: - return False - - return board.user_id == current_user.user_id or board.board_visibility == BoardVisibility.Public - - -def _board_grants_read_access(board_id: str, current_user: CurrentUserOrDefault) -> bool: - """Whether the user may read a board, returning False only when it is confirmed missing or denied.""" - try: - board = ApiDependencies.invoker.services.board_records.get(board_id) + access = ApiDependencies.invoker.services.board_access.get( + board_id, current_user.user_id, current_user.is_admin + ) except BoardRecordNotFoundException: - return False - - if board.user_id == current_user.user_id: - return True - if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): - return True - return ApiDependencies.invoker.services.board_records.is_board_shared_with_user(board_id, current_user.user_id) - + raise HTTPException(status_code=404, detail="Board not found") + if not access.allows(BoardAction.Read): + raise HTTPException( + status_code=403, detail={"code": "board_unreadable", "message": "Not authorized to access this board"} + ) + allows = access.role_allows if inbox_left_to_storage else access.allows + if not all(allows(action) for action in actions): + raise HTTPException( + status_code=403, + detail={"code": "board_action_denied", "message": "Not authorized to change this board"}, + ) + return access + + +def assert_board_changes( + board_id: str, current_user: CurrentUserOrDefault, actions: Iterable[BoardAction] +) -> BoardAccess: + """The caller's access, when their role allows every change; raises otherwise. What a project's inbox refuses + to everyone is left to the storage, which answers it with a 409 naming the project as the place to make the + change. The access is returned so a route can tell an owner acting as one from an admin overriding.""" + return _require(board_id, current_user, actions, inbox_left_to_storage=True) -def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user does not own the image and is not an admin. - Ownership is satisfied when ANY of these hold: - - The user is an admin. - - The user is the image's direct owner (image_records.user_id). - - The user owns the board the image sits on. - - The image sits on a Public board (public boards grant mutation rights). - """ - if current_user.is_admin: - return - if not ApiDependencies.invoker.services.image_records.exists(image_name): - raise HTTPException(status_code=404, detail="Image not found") - owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name) - if owner is not None and owner == current_user.user_id: - return +def assert_board_read_access(board_id: str, current_user: CurrentUserOrDefault) -> None: + """Raise unless the caller may read the board.""" + _require(board_id, current_user, [BoardAction.Read], inbox_left_to_storage=False) - board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) - if board_id is not None and _board_grants_contribution(board_id, current_user): - return - raise HTTPException(status_code=403, detail="Not authorized to modify this image") +def assert_board_write_access(board_id: str | None, current_user: CurrentUserOrDefault) -> None: + """Raise unless the caller may put media on this board. `None` means "no board" and is always allowed, so + upload routes can pass their optional board straight through.""" + if board_id is not None: + _require(board_id, current_user, [BoardAction.AddMedia], inbox_left_to_storage=False) def _assert_image_record_exists(image_name: str) -> None: @@ -104,145 +102,52 @@ def _assert_image_record_exists(image_name: str) -> None: raise HTTPException(status_code=404, detail="Image not found") -def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user may not view the image. - - Access is granted when ANY of these hold: - - The user is an admin. - - The user owns the image. - - The user owns the board the image sits on. - - The image sits on a shared or public board. - - The image sits on a board explicitly shared with the user. +def assert_image_action(image_name: str, current_user: CurrentUserOrDefault, action: ItemAction) -> None: + """Raise 403 unless the caller may do the action to the image, or 404 when the image is gone. - Board-backed images defer to `assert_board_read_access` so individual image - reads stay consistent with board listings (including board_id="all"). + The refusal stays 403 for an image that exists: a client drops its references to an image it is told is gone, + and a refusal can be reversed (see `_assert_image_record_exists`). """ if current_user.is_admin: return - if not ApiDependencies.invoker.services.image_records.exists(image_name): - raise HTTPException(status_code=404, detail="Image not found") - - owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name) - if owner is not None and owner == current_user.user_id: - return - - board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) - if board_id is not None and _board_grants_read_access(board_id, current_user): - return - - _assert_image_record_exists(image_name) - raise HTTPException(status_code=403, detail="Not authorized to access this image") - - -def assert_board_write_access(board_id: str | None, current_user: CurrentUserOrDefault) -> BoardRecord | None: - """Raise if the current user may not put media on this board. - - `None` means "no board" — always allowed, so upload routes can pass their optional board - straight through. Otherwise access is granted when the user is an admin, owns the board, or - the board is Public (public boards accept contributions from any user). - - Shared boards are deliberately read-only here: `Shared` grants visibility, not contribution. - - Returns the board record it checked (None for no board) so callers that need the board — the - upload routes announce the upload with its visibility — do not look it up a second time. - """ - if board_id is None: - return None - - board = _get_board_record(board_id) - - if current_user.is_admin: - return board - if board.user_id == current_user.user_id: - return board - if board.board_visibility == BoardVisibility.Public: - return board - - raise HTTPException(status_code=403, detail="Not authorized to modify this board") - - -def board_share_recipients(board: BoardRecord | None) -> list[str]: - """The users who can see a private board through an explicit share. - - Empty for no board and for Shared/Public boards, whose uploads are announced to everyone - rather than enumerated. This is how an upload route tells the socket layer which extra - users to notify without the socket layer reaching into storage per event. - """ - if board is None or board.board_visibility is not BoardVisibility.Private: - return [] - - return ApiDependencies.invoker.services.board_records.get_shared_user_ids(board.board_id) - - -def assert_video_read_access(video_name: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user may not view the video. - - Deliberately identical in shape to `assert_image_read_access`, and delegating to - `assert_board_read_access` for the same reason: the video twin used to inline a narrower rule - that recognized only Shared and Public visibility, so a video on a board explicitly shared with - you was unreadable while the image beside it was fine. Two media kinds on one board should not - disagree about who can see them. - """ - if current_user.is_admin: - return - - owner = ApiDependencies.invoker.services.video_records.get_user_id(video_name) - if owner is not None and owner == current_user.user_id: - return - - board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name) - if board_id is not None and _board_grants_read_access(board_id, current_user): - return - - if not ApiDependencies.invoker.services.video_records.exists(video_name): - raise HTTPException(status_code=404, detail="Video not found") - raise HTTPException(status_code=403, detail="Not authorized to access this video") - - -def assert_video_owner(video_name: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user may not mutate the video. - - The mutation twin of `assert_image_owner`: the direct owner, the owner of the board it sits on, - or a Public board, which grants contribution rights. - """ - if current_user.is_admin: - return - - owner = ApiDependencies.invoker.services.video_records.get_user_id(video_name) - if owner is not None and owner == current_user.user_id: - return - - board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name) - if board_id is not None and _board_grants_contribution(board_id, current_user): - return - - raise HTTPException(status_code=403, detail="Not authorized to modify this video") + try: + ApiDependencies.invoker.services.board_access.require_image( + image_name, current_user.user_id, current_user.is_admin, action + ) + except ItemAccessDeniedError: + _assert_image_record_exists(image_name) + raise HTTPException(status_code=403, detail="Not authorized to access this image") -def assert_board_read_access(board_id: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user may not read images from this board. +def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None: + """Raise unless the caller may view the image: their own, or on a board they may read.""" + assert_image_action(image_name, current_user, ItemAction.Read) - Access is granted when ANY of these hold: - - The user is an admin. - - The user owns the board. - - The board visibility is Shared or Public. - - The board is explicitly shared with the user. - """ - board = _get_board_record(board_id) +def assert_video_action(video_name: str, current_user: CurrentUserOrDefault, action: ItemAction) -> None: + """The video twin of `assert_image_action`: two media kinds on one board follow one rule.""" if current_user.is_admin: return + try: + ApiDependencies.invoker.services.board_access.require_video( + video_name, current_user.user_id, current_user.is_admin, action + ) + except ItemAccessDeniedError: + if not ApiDependencies.invoker.services.video_records.exists(video_name): + raise HTTPException(status_code=404, detail="Video not found") + raise HTTPException(status_code=403, detail="Not authorized to access this video") - if board.user_id == current_user.user_id: - return - if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): - return +def assert_video_read_access(video_name: str, current_user: CurrentUserOrDefault) -> None: + """Raise unless the caller may view the video: their own, or on a board they may read.""" + assert_video_action(video_name, current_user, ItemAction.Read) - if ApiDependencies.invoker.services.board_records.is_board_shared_with_user(board_id, current_user.user_id): - return - raise HTTPException(status_code=403, detail="Not authorized to access this board") +def item_action_for_changes(changes: object) -> ItemAction: + """Starring someone else's item is the board's managers' to do; any other change to its record is its + owner's.""" + fields = getattr(changes, "model_fields_set", set()) + return ItemAction.Star if fields and fields <= {"starred"} else ItemAction.Edit def assert_project_owned(project_id: str | None, current_user: CurrentUserOrDefault) -> None: @@ -258,3 +163,30 @@ def assert_project_owned(project_id: str | None, current_user: CurrentUserOrDefa ApiDependencies.invoker.services.project_records.get_board_id(current_user.user_id, project_id) except ProjectRecordNotFoundError: raise HTTPException(status_code=404, detail="Project not found") + + +class _MediaDTO(Protocol): + """What the redaction needs of an image or video DTO.""" + + project_id: Optional[str] + + +def redact_project_ids(dtos: Iterable[_MediaDTO], current_user: CurrentUserOrDefault) -> None: + """Blank `project_id` on every DTO whose project is not the caller's own, in place and in one query. + + Provenance is only ever recorded from the uploader's own project (`assert_project_owned`), so a viewer of a + shared board has no business learning it: a project id confirmed here, combined with the 409 on creating a + project with an existing id, would tell them the project exists. Who sees which project is the access + service's decision (`visible_project_ids`); this only applies it to the response. + """ + attributed = [dto for dto in dtos if dto.project_id is not None] + if not attributed: + return + visible = ApiDependencies.invoker.services.board_access.visible_project_ids( + {dto.project_id for dto in attributed if dto.project_id is not None}, + current_user.user_id, + current_user.is_admin, + ) + for dto in attributed: + if dto.project_id not in visible: + dto.project_id = None diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index 1d935992c19..ed5586308c8 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -8,8 +8,12 @@ from invokeai.app.api.routers._access import ( assert_board_write_access as _assert_board_write_access, ) +from invokeai.app.api.routers._access import ( + assert_image_action as _assert_image_action, +) from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.api.routers.images import MAX_IMAGE_BATCH_SIZE, ImageName +from invokeai.app.services.board_access.board_access_common import ItemAction from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult @@ -87,11 +91,8 @@ def _remove_from_board_and_classify(image_name: str, old_board_id: str) -> _Scop def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: """Raise 403 if the current user is not the direct owner of the image. - This is intentionally stricter than _assert_image_owner in images.py: - board ownership is NOT sufficient here. Allowing a user to add someone - else's image to their own board would grant them mutation rights via the - board-ownership fallback in _assert_image_owner, escalating read access - into write access. + Only an image's owner may put it on a board, whatever their role on the board: adding someone else's image to + a board you manage would let you remove and star it there, escalating read access into write access. """ if current_user.is_admin: return @@ -155,7 +156,7 @@ def remove_image_from_board( try: old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" if old_board_id != "none": - _assert_board_write_access(old_board_id, current_user) + _assert_image_action(image_name, current_user, ItemAction.RemoveFromBoard) assert_image_move_maintenance_inactive() removed_images: set[str] = set() failed_images: set[str] = set() @@ -317,7 +318,7 @@ def remove_images_from_board( # letting it escape from inside this handler would replace the 409 with a 500. continue if old_board_id != "none": - _assert_board_write_access(old_board_id, current_user) + _assert_image_action(image_name, current_user, ItemAction.RemoveFromBoard) raise try: @@ -348,10 +349,9 @@ def remove_images_from_board( # The one authorization decision, and it is taken fresh for every name. Memoizing # it per board is tempting — the skip removed the early abort that used to cap an # unauthorized batch at one check — but a cached True outlives the permission it - # recorded: flip a board from Public to Private mid-batch and the rest of the names - # are removed on an answer that is no longer true. _assert_board_write_access reads - # the board record (one indexed SELECT), so re-deciding costs about what the - # get_dto() it replaced cost once. + # recorded: restrict a board's general access mid-batch and the rest of the names + # are removed on an answer that is no longer true. The decision reads the image's + # owner, its board, and that board's access facts (indexed SELECTs). # # Kept out of the try below so that the only way to skip a name for auth is this # branch. Note the two failure modes are not the same: "not allowed" is a skip, @@ -359,7 +359,7 @@ def remove_images_from_board( # could not decide about must be reported rather than silently dropped. if old_board_id != "none": try: - _assert_board_write_access(old_board_id, current_user) + _assert_image_action(image_name, current_user, ItemAction.RemoveFromBoard) except HTTPException: continue except Exception: diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 24a9b130e0b..84c8072e5f6 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -6,15 +6,19 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers._access import assert_board_changes as _assert_board_changes from invokeai.app.api.routers._access import assert_board_read_access as _assert_board_read_access from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive +from invokeai.app.services.board_access.board_access_common import ( + BoardAction, + BoardViewer, +) from invokeai.app.services.board_records.board_records_common import ( BoardChanges, BoardRecordInboxException, BoardRecordNotFoundException, BoardRecordOrderBy, BoardRecordProjectNotFoundException, - BoardRecordProjectUnavailableException, ) from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.image_records.image_records_common import ImageCategory @@ -67,7 +71,7 @@ def create_board( """Creates a board for the current user, in one of their projects or in the Library""" try: return ApiDependencies.invoker.services.boards.create( - board_name=board_name, user_id=current_user.user_id, project_id=project_id + board_name=board_name, user_id=current_user.user_id, project_id=project_id, viewer=_viewer(current_user) ) except BoardRecordProjectNotFoundException: raise HTTPException(status_code=404, detail="Project not found") @@ -84,13 +88,35 @@ def get_board( _assert_board_read_access(board_id, current_user) - # Only a board that is not there is a 404: clients take it as the board being gone, and clear what names it. + # Only a board that is not there is a 404: clients take it as the board being gone and clear what names it. One + # the caller cannot open answered 403 above, a refusal its owner can reverse. try: - return ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) + return ApiDependencies.invoker.services.boards.get_dto(board_id=board_id, viewer=_viewer(current_user)) except BoardRecordNotFoundException: raise HTTPException(status_code=404, detail="Board not found") +def _viewer(current_user: CurrentUserOrDefault) -> BoardViewer: + return BoardViewer(user_id=current_user.user_id, is_admin=current_user.is_admin) + + +def _actions_for(changes: BoardChanges) -> list[BoardAction]: + """The board actions a change takes, one per field it sets.""" + actions: list[BoardAction] = [] + if changes.board_name is not None: + actions.append(BoardAction.Rename) + if changes.cover_image_name is not None: + actions.append(BoardAction.SetCover) + if changes.archived is not None: + actions.append(BoardAction.Archive) + if changes.general_access is not None: + actions.append(BoardAction.SetGeneralAccess) + if changes.moves_board: + actions.append(BoardAction.Move) + # An update that names no field still writes the board: it takes an editor, as any change does. + return actions or [BoardAction.Rename] + + @boards_router.patch( "/{board_id}", operation_id="update_board", @@ -107,24 +133,19 @@ def update_board( board_id: str = Path(description="The id of board to update"), changes: BoardChanges = Body(description="The changes to apply to the board"), ) -> BoardDTO: - """Updates a board (user must have access to it). + """Updates a board. Each field takes its own board action: renaming, setting the cover and archiving need an + editor; general access and moving between the Library and a project need the owner. - A project's inbox takes its name, archived state and visibility from the project and cannot be - moved, so those changes are refused for it — for admins too; its cover is still fair game. A - board in a project is private and unshared, so a move into a project, or a visibility change on - a member, is refused when the result would be otherwise. The storage decides all of this in one - transaction; the DTO read here is only for the ownership check. + A project's inbox takes its name, archived state and general access from the project and cannot be moved, + so those changes are refused for it — for admins too; its cover is still fair game. The storage decides + that in one transaction. """ - try: - board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) - except Exception: - raise HTTPException(status_code=404, detail="Board not found") - - if not current_user.is_admin and board.user_id != current_user.user_id: - raise HTTPException(status_code=403, detail="Not authorized to update this board") + _assert_board_changes(board_id, current_user, _actions_for(changes)) try: - return ApiDependencies.invoker.services.boards.update(board_id=board_id, changes=changes) + return ApiDependencies.invoker.services.boards.update( + board_id=board_id, changes=changes, viewer=_viewer(current_user) + ) except BoardRecordInboxException: raise HTTPException( status_code=409, @@ -132,8 +153,6 @@ def update_board( ) except BoardRecordProjectNotFoundException: raise HTTPException(status_code=404, detail="Project not found") - except BoardRecordProjectUnavailableException: - raise HTTPException(status_code=409, detail="Boards in a project must be private and unshared") except Exception: raise HTTPException(status_code=500, detail="Failed to update board") @@ -146,19 +165,15 @@ def delete_board( description="Permanently delete all images and videos on the board", default=False ), ) -> DeleteBoardResult: - """Deletes a board (user must have access to it)""" - try: - board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) - except Exception: - raise HTTPException(status_code=404, detail="Board not found") - - if not current_user.is_admin and board.user_id != current_user.user_id: - raise HTTPException(status_code=403, detail="Not authorized to delete this board") - - # Admins delete everything on the board; regular owners only delete their own - # contributions so that contributions from other users to a public/shared board - # are preserved (they cascade to "uncategorized" via FK on board_videos / board_images). - cascade_user_id: Optional[str] = None if current_user.is_admin else current_user.user_id + """Deletes a board: its owner's, or an admin's, to do""" + access = _assert_board_changes(board_id, current_user, [BoardAction.Delete]) + + # An owner deleting their board, admin or not, only deletes their own contributions, so what + # other users put on a shared board is preserved (it cascades to "uncategorized" via the FK on + # board_videos / board_images). Only an admin overriding, deleting someone else's board, + # deletes everything on it; that is the case the DTO reports as `admin_override`, so the client + # can warn before it. + cascade_user_id: Optional[str] = None if access.admin_override else current_user.user_id deleted_images: list[str] = [] deleted_videos: list[str] = [] board_deleted = False @@ -190,7 +205,7 @@ def delete_board( ) if not ApiDependencies.invoker.services.boards.delete_if_unclaimed(board_id=board_id): - # `get_dto` above proved the board existed, so it is either an inbox now or it vanished + # The access check above found the board, so it is either an inbox now or it vanished # in between. Ask again rather than trusting the DTO, which predates any concurrent claim. if board_id in ApiDependencies.invoker.services.board_records.get_inbox_board_ids([board_id]): raise HTTPException( diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 874efd5f424..aa66a2f525c 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -21,18 +21,20 @@ assert_board_write_access as _assert_board_write_access, ) from invokeai.app.api.routers._access import ( - assert_image_owner as _assert_image_owner, + assert_image_action as _assert_image_action, ) from invokeai.app.api.routers._access import ( assert_image_read_access as _assert_image_read_access, ) -from invokeai.app.api.routers._access import assert_project_owned from invokeai.app.api.routers._access import ( - board_share_recipients as _board_share_recipients, + assert_project_owned, + item_action_for_changes, + redact_project_ids, ) from invokeai.app.api.routers._limits import MAX_COPY_BATCH_SIZE from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.invocations.fields import MetadataField +from invokeai.app.services.board_access.board_access_common import ItemAction from invokeai.app.services.image_records.image_records_common import ( ImageCategory, ImageNamesResult, @@ -64,11 +66,10 @@ # in the videos router. # # "At least one" is doing real work in that sentence. The authorization helpers in _access.py -# short-circuit on the first hit, so an admin or a direct owner costs 0-1 queries per name -- -# but a user reading someone else's Shared/Public board falls all the way through to -# boards.get_dto(), which is six queries including three COUNT aggregates over the board's -# contents. That is the case the bound has to hold, and it is why no route gets a laxer one: -# the oversized selections this cap rejects are split client-side instead. +# short-circuit for an admin (no query) and a direct owner (one), but a user acting on someone +# else's item on a board they share costs three indexed reads per name: the item's owner, its +# board, and that board's access facts. That is the case the bound has to hold, and it is why no +# route gets a laxer one: the oversized selections this cap rejects are split client-side instead. MAX_IMAGE_BATCH_SIZE = 1000 # Names are UUID-derived filenames; the bound only exists to keep a hostile body from # turning into megabytes of SQL parameters. @@ -132,9 +133,7 @@ async def upload_image( ), ) -> ImageDTO: """Uploads an image for the current user""" - # If uploading into a board, verify the user has write access. - # Public boards allow uploads from any authenticated user. - board = await asyncio.to_thread(_assert_board_write_access, board_id, current_user) + await asyncio.to_thread(_assert_board_write_access, board_id, current_user) await asyncio.to_thread(assert_project_owned, project_id, current_user) await asyncio.to_thread(assert_image_move_maintenance_inactive) @@ -207,10 +206,7 @@ def _resize() -> Image.Image: raise HTTPException(status_code=500, detail="Failed to create image") if not is_intermediate and is_gallery_category(image_category): - shared_user_ids = await asyncio.to_thread(_board_share_recipients, board) - ApiDependencies.invoker.services.events.emit_image_uploaded( - image_dto, user_id=current_user.user_id, board=board, shared_user_ids=shared_user_ids - ) + ApiDependencies.invoker.services.events.emit_image_uploaded(image_dto, user_id=current_user.user_id) response.status_code = 201 response.headers["Location"] = image_dto.image_url @@ -241,7 +237,7 @@ def delete_image( image_name: str = Path(description="The name of the image to delete"), ) -> DeleteImagesResult: """Deletes an image""" - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Delete) assert_image_move_maintenance_inactive() # Let service-level failures surface as errors rather than swallowing them and returning @@ -325,13 +321,16 @@ def update_image( image_changes: ImageRecordChanges = Body(description="The changes to apply to the image"), ) -> ImageDTO: """Updates an image""" - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, item_action_for_changes(image_changes)) assert_image_move_maintenance_inactive() try: - return ApiDependencies.invoker.services.images.update(image_name, image_changes) + image_dto = ApiDependencies.invoker.services.images.update(image_name, image_changes) except Exception: raise HTTPException(status_code=400, detail="Failed to update image") + # Starring is a board role's to do, so the caller may be a member reading another member's item. + redact_project_ids([image_dto], current_user) + return image_dto @images_router.get( @@ -347,7 +346,7 @@ def get_image_dto( _assert_image_read_access(image_name, current_user) try: - return ApiDependencies.invoker.services.images.get_dto(image_name) + image_dto = ApiDependencies.invoker.services.images.get_dto(image_name) except ImageRecordNotFoundException: # Only a genuinely missing record answers 404. This route is what a workflow's image # field and a canvas reference image read, and they drop the user's reference when it @@ -356,6 +355,8 @@ def get_image_dto( # workflows using it. Narrowed here rather than everywhere, because this is the 404 # that is acted on destructively — the media, metadata and workflow routes keep theirs. raise HTTPException(status_code=404) + redact_project_ids([image_dto], current_user) + return image_dto @images_router.get( @@ -561,6 +562,7 @@ def list_image_dtos( user_id=current_user.user_id, is_admin=current_user.is_admin, ) + redact_project_ids(image_dtos.items, current_user) return image_dtos @@ -576,7 +578,7 @@ def delete_images_from_list( assert_image_move_maintenance_inactive() except HTTPException: for image_name in image_names: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Delete) raise try: @@ -599,7 +601,7 @@ def delete_images_from_list( # only the delete lost the race. board_id: str | None = None try: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Delete) image_dto = ApiDependencies.invoker.services.images.get_dto(image_name) board_id = image_dto.board_id or "none" ApiDependencies.invoker.services.images.delete(image_name) @@ -666,7 +668,7 @@ def delete_uncategorized_images( affected_boards: set[str] = set() for image_name in image_names: try: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Delete) ApiDependencies.invoker.services.images.delete(image_name) deleted_images.add(image_name) affected_boards.add("none") @@ -760,7 +762,7 @@ def star_images_in_list( assert_image_move_maintenance_inactive() except HTTPException: for image_name in image_names: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Star) raise try: @@ -777,7 +779,7 @@ def star_images_in_list( # twice and could land in both starred_images and failed_images. for image_name in dict.fromkeys(image_names): try: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Star) updated_image_dto = ApiDependencies.invoker.services.images.update( image_name, changes=ImageRecordChanges(starred=True) ) @@ -818,7 +820,7 @@ def unstar_images_in_list( assert_image_move_maintenance_inactive() except HTTPException: for image_name in image_names: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Star) raise try: @@ -829,7 +831,7 @@ def unstar_images_in_list( affected_boards: set[str] = set() for image_name in dict.fromkeys(image_names): try: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Star) updated_image_dto = ApiDependencies.invoker.services.images.update( image_name, changes=ImageRecordChanges(starred=False) ) @@ -1035,6 +1037,7 @@ def get_images_by_names( # Skip missing images - they may have been deleted between name fetch and DTO fetch continue + redact_project_ids(image_dtos, current_user) return image_dtos except Exception: raise HTTPException(status_code=500, detail="Failed to get image DTOs") diff --git a/invokeai/app/api/routers/projects.py b/invokeai/app/api/routers/projects.py index 6e3aba95086..be4241f675b 100644 --- a/invokeai/app/api/routers/projects.py +++ b/invokeai/app/api/routers/projects.py @@ -8,6 +8,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.project_records.project_records_common import ( DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, PROJECT_DOCUMENT_MAX_BYTES, ProjectBoardNotFoundError, ProjectBoardSnapshotDTO, @@ -16,12 +17,19 @@ ProjectCanvasSchemaDowngradeError, ProjectCanvasSchemaUnsupportedError, ProjectDocumentInvalidError, + ProjectDocumentSchemaDowngradeError, + ProjectDocumentSchemaUnsupportedError, ProjectDocumentTooLargeError, ProjectRecordConflictError, - ProjectRecordDTO, ProjectRecordExistsError, ProjectRecordNotFoundError, + ProjectSplitNeedsWorkspaceError, ProjectSummaryDTO, + ProjectUpdateWorkspaceRequest, + ProjectWithWorkspaceDTO, + ProjectWorkspaceBeforeSplitError, + ProjectWorkspaceConflictError, + ProjectWorkspaceDTO, ) projects_router = APIRouter(prefix="/v1/projects", tags=["projects"]) @@ -61,6 +69,28 @@ class ProjectCreateRequest(BaseModel): ge=1, description="Newest canvas schema understood by this client", ) + minimum_document_schema_version: int = Field( + default=DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ge=1, + description="Oldest project document schema that can read this document", + ) + max_document_schema_version: int = Field( + default=DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ge=1, + description="Newest project document schema understood by this client", + ) + workspace: dict[str, Any] | None = Field( + default=None, + description=( + "The caller's first workspace in the project, stored with the content in one transaction, so that no" + " project is created without its creator's workspace. The workspace document itself: a create's" + " workspace is always the first row, so it carries no revision. Checked as" + " `PUT /projects/{id}/workspace` checks it, except that `max_canvas_schema_version` covers it. Needs" + " `minimum_document_schema_version` at 4 or above, where a project is content and workspaces; beside a" + " whole document it is refused with 400 (workspace_needs_split_document). Omit it to create the" + " project without one." + ), + ) class ProjectUpdateRequest(BaseModel): @@ -79,6 +109,83 @@ class ProjectUpdateRequest(BaseModel): ge=1, description="Newest canvas schema understood by this client", ) + minimum_document_schema_version: int | None = Field( + default=None, + ge=1, + description=( + "New document schema floor to store atomically with this content; omitted to keep the current floor" + ), + ) + max_document_schema_version: int = Field( + default=DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ge=1, + description="Newest project document schema understood by this client", + ) + workspace: ProjectUpdateWorkspaceRequest | None = Field( + default=None, + description=( + "The caller's workspace, to store in the same transaction as the content. For the save that splits a" + " whole project (document schema 3 or older) into content and workspace while raising the document" + " floor: the floor, the content and the first workspace row then land together, never by half. Its" + " revision is checked as on the workspace route, and a mismatch refuses the whole save with 409. The" + " save must leave the document floor at 4 or above, or it is refused whole with 400" + " (workspace_needs_split_document). Omit it on every other save; the workspace saves apart, on its own" + " route." + ), + ) + + +class ProjectWorkspaceSaveRequest(BaseModel): + """Request body for saving the caller's workspace in a project with optimistic concurrency.""" + + data: dict[str, Any] = Field(description="The opaque client-owned workspace document") + expected_revision: int | None = Field( + description="The workspace revision this save is based on, or null for the caller's first workspace in the" + " project; a mismatch returns 409" + ) + max_document_schema_version: int = Field( + ge=1, description="Newest project document schema understood by this client" + ) + max_canvas_schema_version: int = Field( + default=DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + ge=1, + description="Newest canvas schema understood by this client; the workspace holds canvas state", + ) + + +def _document_schema_precondition_failed(error: ProjectDocumentSchemaUnsupportedError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail={ + "code": "document_schema_unsupported", + "message": str(error), + "minimum_document_schema_version": error.minimum_version, + "max_document_schema_version": error.client_maximum_version, + }, + ) + + +def _document_schema_downgrade(error: ProjectDocumentSchemaDowngradeError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "document_schema_downgrade", + "message": str(error), + "current_minimum_document_schema_version": error.current_version, + "requested_minimum_document_schema_version": error.requested_version, + }, + ) + + +def _workspace_before_split(error: ProjectWorkspaceBeforeSplitError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "workspace_needs_split_document", + "message": str(error), + "minimum_document_schema_version": error.document_floor, + }, + ) def _schema_precondition_failed(error: ProjectCanvasSchemaUnsupportedError) -> HTTPException: @@ -119,13 +226,15 @@ def list_projects(current_user: CurrentUserOrDefault) -> list[ProjectSummaryDTO] @projects_router.post( - "/", operation_id="create_project", response_model=ProjectRecordDTO, status_code=status.HTTP_201_CREATED + "/", operation_id="create_project", response_model=ProjectWithWorkspaceDTO, status_code=status.HTTP_201_CREATED ) def create_project( current_user: CurrentUserOrDefault, request: ProjectCreateRequest = Body(description="The project to create"), -) -> ProjectRecordDTO: - """Creates a project, and the private inbox board it owns, for the current user.""" +) -> ProjectWithWorkspaceDTO: + """Creates a project, and the private inbox board it owns, for the current user, and with it, when the request + carries `workspace`, the caller's workspace in it, in one transaction. Returns the record with the workspace + this create stored (null when it carried none).""" try: return ApiDependencies.invoker.services.project_records.create( user_id=current_user.user_id, @@ -135,6 +244,9 @@ def create_project( board_id=request.board_id, minimum_canvas_schema_version=request.minimum_canvas_schema_version, max_canvas_schema_version=request.max_canvas_schema_version, + minimum_document_schema_version=request.minimum_document_schema_version, + max_document_schema_version=request.max_document_schema_version, + workspace=request.workspace, ) except (ProjectRecordExistsError, ProjectBoardUnavailableError) as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) @@ -142,13 +254,17 @@ def create_project( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ProjectCanvasSchemaUnsupportedError as e: raise _schema_precondition_failed(e) + except ProjectDocumentSchemaUnsupportedError as e: + raise _document_schema_precondition_failed(e) + except ProjectWorkspaceBeforeSplitError as e: + raise _workspace_before_split(e) except ProjectDocumentTooLargeError as e: raise _document_too_large(e) except ProjectDocumentInvalidError as e: raise _document_invalid(e) -@projects_router.get("/{project_id}", operation_id="get_project", response_model=ProjectRecordDTO) +@projects_router.get("/{project_id}", operation_id="get_project", response_model=ProjectWithWorkspaceDTO) def get_project( current_user: CurrentUserOrDefault, project_id: str = Path(description="The id of the project to get"), @@ -157,25 +273,37 @@ def get_project( ge=1, description="Newest canvas schema understood by this client", ), -) -> ProjectRecordDTO: - """Gets one of the current user's projects, including its document.""" + max_document_schema_version: int = Query( + default=DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ge=1, + description="Newest project document schema understood by this client", + ), +) -> ProjectWithWorkspaceDTO: + """Gets one of the current user's projects: its content and the caller's workspace in it.""" try: return ApiDependencies.invoker.services.project_records.get( - current_user.user_id, project_id, max_canvas_schema_version=max_canvas_schema_version + current_user.user_id, + project_id, + max_canvas_schema_version=max_canvas_schema_version, + max_document_schema_version=max_document_schema_version, ) except ProjectRecordNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ProjectCanvasSchemaUnsupportedError as e: raise _schema_precondition_failed(e) + except ProjectDocumentSchemaUnsupportedError as e: + raise _document_schema_precondition_failed(e) -@projects_router.put("/{project_id}", operation_id="update_project", response_model=ProjectRecordDTO) +@projects_router.put("/{project_id}", operation_id="update_project", response_model=ProjectWithWorkspaceDTO) def update_project( current_user: CurrentUserOrDefault, project_id: str = Path(description="The id of the project to save"), request: ProjectUpdateRequest = Body(description="The project document and the revision it is based on"), -) -> ProjectRecordDTO: - """Saves a project. Returns 409 with the current revision when the save is based on a stale revision.""" +) -> ProjectWithWorkspaceDTO: + """Saves a project's content and, when the request carries `workspace`, the caller's workspace in it, in one + transaction. Returns the record with the workspace this save stored (null when it carried none). Returns 409 + with the current revision when the save is based on a stale revision of either.""" try: return ApiDependencies.invoker.services.project_records.update( user_id=current_user.user_id, @@ -185,10 +313,13 @@ def update_project( data=request.data, minimum_canvas_schema_version=request.minimum_canvas_schema_version, max_canvas_schema_version=request.max_canvas_schema_version, + minimum_document_schema_version=request.minimum_document_schema_version, + max_document_schema_version=request.max_document_schema_version, + workspace=request.workspace, ) except ProjectRecordNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - except ProjectRecordConflictError as e: + except (ProjectRecordConflictError, ProjectWorkspaceConflictError) as e: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"message": str(e), "current_revision": e.current_revision}, @@ -203,8 +334,63 @@ def update_project( "requested_minimum_canvas_schema_version": e.requested_version, }, ) + except ProjectDocumentSchemaDowngradeError as e: + raise _document_schema_downgrade(e) + except ProjectWorkspaceBeforeSplitError as e: + raise _workspace_before_split(e) + except ProjectSplitNeedsWorkspaceError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "split_needs_workspace", + "message": str(e), + "minimum_document_schema_version": e.document_floor, + }, + ) + except ProjectCanvasSchemaUnsupportedError as e: + raise _schema_precondition_failed(e) + except ProjectDocumentSchemaUnsupportedError as e: + raise _document_schema_precondition_failed(e) + except ProjectDocumentTooLargeError as e: + raise _document_too_large(e) + except ProjectDocumentInvalidError as e: + raise _document_invalid(e) + + +@projects_router.put( + "/{project_id}/workspace", operation_id="save_project_workspace", response_model=ProjectWorkspaceDTO +) +def save_project_workspace( + current_user: CurrentUserOrDefault, + project_id: str = Path(description="The id of the project whose workspace to save"), + request: ProjectWorkspaceSaveRequest = Body(description="The workspace and the revision it is based on"), +) -> ProjectWorkspaceDTO: + """Saves the caller's workspace in one of their projects: their layout, widgets and generation settings, apart + from the project content, so that saving one never conflicts with the other. Returns 409 with the current + revision (null when there is none) when the save is based on another, and 400 `workspace_needs_split_document` + while the project is still held whole: the workspace is saved only beside content at the split schema.""" + try: + return ApiDependencies.invoker.services.project_records.save_workspace( + user_id=current_user.user_id, + project_id=project_id, + expected_revision=request.expected_revision, + data=request.data, + max_document_schema_version=request.max_document_schema_version, + max_canvas_schema_version=request.max_canvas_schema_version, + ) + except ProjectRecordNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ProjectWorkspaceConflictError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"message": str(e), "current_revision": e.current_revision}, + ) + except ProjectWorkspaceBeforeSplitError as e: + raise _workspace_before_split(e) except ProjectCanvasSchemaUnsupportedError as e: raise _schema_precondition_failed(e) + except ProjectDocumentSchemaUnsupportedError as e: + raise _document_schema_precondition_failed(e) except ProjectDocumentTooLargeError as e: raise _document_too_large(e) except ProjectDocumentInvalidError as e: diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index 0c1471a1d26..2d3c0221abc 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -202,6 +202,15 @@ def _get_queue_item_for_retry(item_id: int) -> SessionQueueItem: AnyQueueItem = TypeVar("AnyQueueItem", SessionQueueItem, SessionQueueItemSummary) +def origin_owner(current_user: CurrentUserOrDefault, origin_prefix: Optional[str]) -> Optional[str]: + """The account an origin-scoped read is limited to: the caller's own, unless they are an admin. + + A project's origin names one account's project, so another account's items under it are not part of it. Matching + the prefix against everyone's items would also let any caller probe, prefix by prefix, for other accounts' project + ids.""" + return None if origin_prefix is None or current_user.is_admin else current_user.user_id + + def sanitize_queue_item_for_user(queue_item: AnyQueueItem, current_user_id: str, is_admin: bool) -> AnyQueueItem: """Sanitize a queue item, or a queue item summary, for a non-admin viewing another user's item. @@ -347,7 +356,11 @@ def get_queue_item_ids( queue_id: str = Path(description="The queue id to perform this operation on"), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), origin_prefix: Optional[str] = Query( - default=None, description="Only include queue items whose origin starts with this prefix" + default=None, + description=( + "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own" + " items match" + ), ), limit: Optional[int] = Query( default=None, @@ -363,7 +376,8 @@ def get_queue_item_ids( returned, and it is not a total (counting every match would read the whole queue history); use the queue status for totals. - IDs for every user's items are returned (item ids carry no sensitive data on their own). + Without `origin_prefix`, IDs for every user's items are returned (item ids carry no sensitive data on their + own); with it, a non-admin gets only their own. When the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging to other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see partially-redacted entries for other users' jobs in the queue list, while still revealing @@ -373,7 +387,11 @@ def get_queue_item_ids( """ try: return ApiDependencies.invoker.services.session_queue.get_queue_item_ids( - queue_id=queue_id, order_dir=order_dir, origin_prefix=origin_prefix, limit=limit + queue_id=queue_id, + order_dir=order_dir, + owner_id=origin_owner(current_user, origin_prefix), + origin_prefix=origin_prefix, + limit=limit, ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}") @@ -687,12 +705,18 @@ def get_current_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), origin_prefix: Optional[str] = Query( - default=None, description="Only include queue items whose origin starts with this prefix" + default=None, + description=( + "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own" + " items match" + ), ), ) -> Optional[SessionQueueItem]: """Gets the currently execution queue item""" try: - item = ApiDependencies.invoker.services.session_queue.get_current_for_api(queue_id, origin_prefix=origin_prefix) + item = ApiDependencies.invoker.services.session_queue.get_current_for_api( + queue_id, origin_prefix=origin_prefix, owner_id=origin_owner(current_user, origin_prefix) + ) if item is not None: item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) return item @@ -733,12 +757,18 @@ def get_next_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), origin_prefix: Optional[str] = Query( - default=None, description="Only include queue items whose origin starts with this prefix" + default=None, + description=( + "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own" + " items match" + ), ), ) -> Optional[SessionQueueItem]: """Gets the next queue item, without executing it""" try: - item = ApiDependencies.invoker.services.session_queue.get_next_for_api(queue_id, origin_prefix=origin_prefix) + item = ApiDependencies.invoker.services.session_queue.get_next_for_api( + queue_id, origin_prefix=origin_prefix, owner_id=origin_owner(current_user, origin_prefix) + ) if item is not None: item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) return item @@ -757,13 +787,17 @@ def get_queue_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), origin_prefix: Optional[str] = Query( - default=None, description="Only include queue items whose origin starts with this prefix" + default=None, + description=( + "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own" + " items match" + ), ), ) -> SessionQueueAndProcessorStatus: - """Gets the status of the session queue. Returns global counts; every user additionally gets - their own pending/in_progress counts (so the UI can show an X/Y badge and scope personal UI - like the progress bar to the user's own activity). Non-admin users cannot see the current - item's identifiers unless they own it.""" + """Gets the status of the session queue. Returns global counts (a non-admin's counts are their own when + `origin_prefix` is given); every user additionally gets their own pending/in_progress counts (so the UI + can show an X/Y badge and scope personal UI like the progress bar to the user's own activity). Non-admin + users cannot see the current item's identifiers unless they own it.""" try: queue = ApiDependencies.invoker.services.session_queue.get_queue_status( queue_id, @@ -771,6 +805,7 @@ def get_queue_status( acting_user_id=current_user.user_id, origin_prefix=origin_prefix, is_admin=current_user.is_admin, + owner_id=origin_owner(current_user, origin_prefix), ) processor = ApiDependencies.invoker.services.session_processor.get_status() return SessionQueueAndProcessorStatus(queue=queue, processor=processor) diff --git a/invokeai/app/api/routers/video_recall.py b/invokeai/app/api/routers/video_recall.py index a404475845f..54f7f22bbb8 100644 --- a/invokeai/app/api/routers/video_recall.py +++ b/invokeai/app/api/routers/video_recall.py @@ -14,7 +14,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.api.routers._access import assert_image_read_access, assert_video_read_access +from invokeai.app.api.routers._access import assert_image_read_access, assert_video_read_access, redact_project_ids from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.api.routers.videos import VIDEO_UPLOAD_OPENAPI_EXTRA, ingest_uploaded_video from invokeai.app.services.events.events_common import ( @@ -587,6 +587,8 @@ def _place_gallery_video( video = ApiDependencies.invoker.services.videos.get_dto(video_name) except VideoRecordNotFoundException: raise HTTPException(status_code=404, detail="Video not found") + # The response carries the DTO as the videos API answers it: a viewer of a shared board learns no project. + redact_project_ids([video], current_user) return _emit_video_placement(queue_id, current_user.user_id, action, video, False, conditioning_role) diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index 70bfa3456bc..85ec5c05cd8 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -27,19 +27,21 @@ from invokeai.app.api.routers._access import ( assert_board_write_access as _assert_board_write_access, ) -from invokeai.app.api.routers._access import assert_project_owned from invokeai.app.api.routers._access import ( - assert_video_owner as _assert_video_owner, + assert_project_owned, + item_action_for_changes, + redact_project_ids, ) from invokeai.app.api.routers._access import ( - assert_video_read_access as _assert_video_read_access, + assert_video_action as _assert_video_action, ) from invokeai.app.api.routers._access import ( - board_share_recipients as _board_share_recipients, + assert_video_read_access as _assert_video_read_access, ) from invokeai.app.api.routers._limits import MAX_COPY_BATCH_SIZE from invokeai.app.api.routers.images import WorkflowAndGraphResponse from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator +from invokeai.app.services.board_access.board_access_common import ItemAction from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin, is_gallery_category from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults, SQLiteDirection from invokeai.app.services.video_records.video_records_common import ( @@ -152,10 +154,8 @@ def _get_video_cache_control() -> str: def _assert_video_direct_owner(video_name: str, current_user: CurrentUserOrDefault) -> None: """Raise 403 if the current user is not the direct owner of the video. - Intentionally stricter than _assert_video_owner: board-ownership and public-board - fallbacks are NOT honored. Mirrors _assert_image_direct_owner in board_images.py — - board-move operations need to verify the *original* owner, otherwise a user could - move someone else's video onto their own board via the board-owner branch. + Mirrors _assert_image_direct_owner in board_images.py: only a video's owner may put it on a board, whatever + their role on the board, otherwise a user could move someone else's video onto a board they manage. """ if current_user.is_admin: return @@ -443,7 +443,7 @@ async def ingest_uploaded_video( and the concurrency slots are enforced there, before this runs. """ # Check board access for uploads to a specific board. - board = await run_in_threadpool(_assert_board_write_access, board_id, current_user) + await run_in_threadpool(_assert_board_write_access, board_id, current_user) await run_in_threadpool(assert_project_owned, project_id, current_user) # Stream the upload straight into a tmp file so we can probe it and then hand its path @@ -553,10 +553,7 @@ async def ingest_uploaded_video( raise HTTPException(status_code=500, detail="Failed to create video") if not is_intermediate and is_gallery_category(video_category): - shared_user_ids = await run_in_threadpool(_board_share_recipients, board) - ApiDependencies.invoker.services.events.emit_video_uploaded( - video_dto, user_id=current_user.user_id, board=board, shared_user_ids=shared_user_ids - ) + ApiDependencies.invoker.services.events.emit_video_uploaded(video_dto, user_id=current_user.user_id) return video_dto finally: @@ -621,7 +618,7 @@ def delete_video( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of the video to delete"), ) -> DeleteVideosResult: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Delete) # Let service-level failures surface as 500s rather than swallowing them and returning a # success-shaped response. A previous version of this handler caught everything and @@ -668,7 +665,7 @@ def delete_videos_from_list( # in both deleted_videos and failed_videos. for video_name in dict.fromkeys(batch.video_names): try: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Delete) video_dto = ApiDependencies.invoker.services.videos.get_dto(video_name) board_id = video_dto.board_id or "none" ApiDependencies.invoker.services.videos.delete(video_name) @@ -704,7 +701,7 @@ def delete_uncategorized_videos( affected_boards: set[str] = set() for video_name in names_result.video_names: try: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Delete) ApiDependencies.invoker.services.videos.delete(video_name) deleted_videos.add(video_name) affected_boards.add("none") @@ -728,11 +725,13 @@ def update_video( video_name: str = PathParam(description="The name of the video to update"), video_changes: VideoRecordChanges = Body(description="The changes to apply to the video"), ) -> VideoDTO: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, item_action_for_changes(video_changes)) try: - return ApiDependencies.invoker.services.videos.update(video_name, video_changes) + video_dto = ApiDependencies.invoker.services.videos.update(video_name, video_changes) except Exception: raise HTTPException(status_code=400, detail="Failed to update video") + redact_project_ids([video_dto], current_user) + return video_dto @videos_router.get("/i/{video_name}", operation_id="get_video_dto", response_model=VideoDTO) @@ -742,11 +741,13 @@ def get_video_dto( ) -> VideoDTO: _assert_video_read_access(video_name, current_user) try: - return ApiDependencies.invoker.services.videos.get_dto(video_name) + video_dto = ApiDependencies.invoker.services.videos.get_dto(video_name) except VideoRecordNotFoundException: # See get_image_dto: this is the 404 a workflow's video field drops its reference on, # so only a genuinely missing record may produce it. raise HTTPException(status_code=404) + redact_project_ids([video_dto], current_user) + return video_dto @videos_router.get( @@ -1003,7 +1004,7 @@ def list_video_dtos( if board_id is not None and board_id != "none": _assert_board_read_access(board_id, current_user) - return ApiDependencies.invoker.services.videos.get_many( + video_dtos = ApiDependencies.invoker.services.videos.get_many( offset, limit, starred_first, @@ -1016,6 +1017,8 @@ def list_video_dtos( current_user.user_id, current_user.is_admin, ) + redact_project_ids(video_dtos.items, current_user) + return video_dtos @videos_router.get("/names", operation_id="get_video_names", deprecated=True) @@ -1124,7 +1127,7 @@ def star_videos_in_list( affected_boards: set[str] = set() for video_name in dict.fromkeys(batch.video_names): try: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Star) updated = ApiDependencies.invoker.services.videos.update( video_name, changes=VideoRecordChanges(starred=True) ) @@ -1152,7 +1155,7 @@ def unstar_videos_in_list( affected_boards: set[str] = set() for video_name in dict.fromkeys(batch.video_names): try: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Star) updated = ApiDependencies.invoker.services.videos.update( video_name, changes=VideoRecordChanges(starred=False) ) @@ -1211,19 +1214,10 @@ def remove_video_from_board( current_user: CurrentUserOrDefault, video_name: str = Body(description="The name of the video to remove from its board", embed=True), ) -> RemoveVideosFromBoardResult: - # A video association can be removed by EITHER the direct video owner OR a user with - # write access to the destination board (admin, board owner, or any contributor when the - # board is Public). This mirrors remove_image_from_board and prevents a video from being - # stranded when a non-owner uploads into a Public board that is later made Shared/Private: - # without the board-write fallback, neither the uploader nor the board owner could - # detach the video. See PR #9163 review. + # The video's owner may always take it off a board; anyone else needs to manage the board's items, as for + # images. old_board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name) - try: - _assert_video_direct_owner(video_name, current_user) - except HTTPException: - if old_board_id is None: - raise - _assert_board_write_access(old_board_id, current_user) + _assert_video_action(video_name, current_user, ItemAction.RemoveFromBoard) try: ApiDependencies.invoker.services.board_video_records.remove_video_from_board(video_name=video_name) return RemoveVideosFromBoardResult( diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 9446216f8e9..bb7e7fc9c16 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -9,7 +9,6 @@ from starlette.types import Message, Receive, Scope, Send from invokeai.app.services.auth.token_service import verify_token -from invokeai.app.services.board_records.board_records_common import BoardVisibility from invokeai.app.services.config.config_default import get_config from invokeai.app.services.events.events_common import ( BatchEnqueuedEvent, @@ -1263,11 +1262,9 @@ async def _handle_intermediates_event(self, event: FastAPIEvent[IntermediatesEve async def _handle_media_event(self, event: FastAPIEvent[MediaUploadedEventBase]) -> None: """Route an upload to the clients whose gallery can show it. - Shared and Public boards are visible to every user, so those uploads go to everyone. - Anything else — a private board or no board at all — goes to the uploader, the board's - owner (an admin may upload into someone else's private board), the users that board is - explicitly shared with, and admins, who see every board. The route resolves the share - list, so routing an event stays free of storage access. + The board access service says who sees the board: everyone, when its general access is not restricted, or + else its owner and members. The uploader and admins, who see every board, always get it, and so does + everyone involved when the media landed on no board or on one deleted meanwhile. """ event_name, event_data = event payload = event_data.model_dump(mode="json") @@ -1276,16 +1273,25 @@ async def _handle_media_event(self, event: FastAPIEvent[MediaUploadedEventBase]) await self._sio.emit(event=event_name, data=payload, room="admin") return - if event_data.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): + from invokeai.app.api.dependencies import ApiDependencies + + audience = None + if event_data.board_id is not None: + try: + audience = await run_in_threadpool( + ApiDependencies.invoker.services.board_access.audience, event_data.board_id + ) + except Exception: + # The uploader and admins still hear of it; nobody else is told of a board they may not see. + logger.exception(f"Could not tell who sees board {event_data.board_id}; announcing the upload narrowly") + if audience is not None and audience.everyone: await self._sio.emit(event=event_name, data=payload) return rooms = [f"user:{event_data.user_id}", "admin"] - if event_data.board_owner_id is not None and event_data.board_owner_id != event_data.user_id: - rooms.append(f"user:{event_data.board_owner_id}") - # A room the socket is already in costs nothing: python-socketio unions a room list by sid. - rooms.extend(f"user:{user_id}" for user_id in event_data.shared_user_ids) - await self._sio.emit(event=event_name, data=payload, room=rooms) + if audience is not None: + rooms.extend(f"user:{user_id}" for user_id in audience.user_ids) + await self._sio.emit(event=event_name, data=payload, room=list(dict.fromkeys(rooms))) async def _handle_bulk_image_download_event(self, event: FastAPIEvent[BulkDownloadEventBase]) -> None: event_name, event_data = event diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 7bb12fdec4a..4802132c2b6 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -636,7 +636,8 @@ def _is_project_write(method: str, path: str) -> bool: return path == "/api/v1/projects/" if method != "PUT" or not path.startswith("/api/v1/projects/"): return False - project_id = path.removeprefix("/api/v1/projects/") + # A project's content, or the caller's workspace in it: each is a document as large. + project_id = path.removeprefix("/api/v1/projects/").removesuffix("/workspace") return bool(project_id) and "/" not in project_id diff --git a/invokeai/app/services/board_access/board_access_common.py b/invokeai/app/services/board_access/board_access_common.py new file mode 100644 index 00000000000..9c62ac3edc4 --- /dev/null +++ b/invokeai/app/services/board_access/board_access_common.py @@ -0,0 +1,186 @@ +"""Who may do what on a board: roles, the actions they allow, and the one table that maps them.""" + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from invokeai.app.services.users.users_base import UserServiceBase + + +class BoardRole(str, Enum): + """A caller's role on a board. Each role allows everything the roles before it allow.""" + + Viewer = "viewer" + Contributor = "contributor" + Editor = "editor" + Owner = "owner" + + +class GeneralAccess(str, Enum): + """The role every other account gets on a board: none, viewer or contributor.""" + + Restricted = "restricted" + Viewer = "viewer" + Contributor = "contributor" + + +class BoardAction(str, Enum): + """What a caller can do to a board as a whole.""" + + Read = "read" + """Browse the board, open, download and copy out its items, and use them in one's own generations.""" + AddMedia = "add_media" + """Upload to the board, generate into it, and move or copy one's own items onto it.""" + ManageItems = "manage_items" + """Remove anyone's items from the board, and star or unstar them.""" + Rename = "rename" + SetCover = "set_cover" + Archive = "archive" + SetGeneralAccess = "set_general_access" + Move = "move" + """Move the board between the Library and a project.""" + Delete = "delete" + + +class ItemAction(str, Enum): + """What a caller can do to an image or a video. An item's owner may do all of it.""" + + Read = "read" + RemoveFromBoard = "remove_from_board" + Star = "star" + """Star or unstar: a flag every viewer of the item sees.""" + Edit = "edit" + """Change the item's record otherwise (its category, whether it is an intermediate).""" + Delete = "delete" + """Delete the item permanently. Only its owner and admins may: a board's managers can only remove it.""" + + +_RANK = {BoardRole.Viewer: 1, BoardRole.Contributor: 2, BoardRole.Editor: 3, BoardRole.Owner: 4} + +MINIMUM_ROLE: dict[BoardAction, BoardRole] = { + BoardAction.Read: BoardRole.Viewer, + BoardAction.AddMedia: BoardRole.Contributor, + BoardAction.ManageItems: BoardRole.Editor, + BoardAction.Rename: BoardRole.Editor, + BoardAction.SetCover: BoardRole.Editor, + BoardAction.Archive: BoardRole.Editor, + BoardAction.SetGeneralAccess: BoardRole.Owner, + BoardAction.Move: BoardRole.Owner, + BoardAction.Delete: BoardRole.Owner, +} +"""The least role each board action needs: the only copy of who may do what.""" + +# A project's inbox is managed through its project, by everyone, admins included. +_MANAGED_BY_PROJECT = frozenset( + {BoardAction.Rename, BoardAction.Archive, BoardAction.SetGeneralAccess, BoardAction.Move, BoardAction.Delete} +) + +# The board action that lets a caller do an item action to someone else's item. Deleting someone else's item +# permanently is for admins alone. +_BOARD_ACTION_FOR_ITEM: dict[ItemAction, Optional[BoardAction]] = { + ItemAction.Read: BoardAction.Read, + ItemAction.RemoveFromBoard: BoardAction.ManageItems, + ItemAction.Star: BoardAction.ManageItems, + ItemAction.Edit: None, + ItemAction.Delete: None, +} + + +def role_of( + *, owner_id: str, user_id: str, member_role: Optional[BoardRole], general_access: GeneralAccess +) -> Optional[BoardRole]: + """The highest role a caller has on a board: as its owner, as a member, or through general access.""" + if owner_id == user_id: + return BoardRole.Owner + candidates = [member_role] if member_role is not None else [] + if general_access is not GeneralAccess.Restricted: + candidates.append(BoardRole(general_access.value)) + return max(candidates, key=_RANK.__getitem__, default=None) + + +@dataclass(frozen=True) +class BoardAccess: + """A caller's access to one board.""" + + role: Optional[BoardRole] + """The caller's own role, or None when only admin rights give access.""" + is_admin: bool + is_inbox: bool + + @property + def admin_override(self) -> bool: + """Whether admin rights give the caller more than their own role does.""" + return self.is_admin and self.role is not BoardRole.Owner + + def allows(self, action: BoardAction) -> bool: + return not (self.is_inbox and action in _MANAGED_BY_PROJECT) and self.role_allows(action) + + def role_allows(self, action: BoardAction) -> bool: + """Whether the caller's role, or admin rights, allow the action, setting aside what a project's inbox + reserves to its project: for a caller that leaves that refusal to the storage.""" + if self.is_admin: + return True + return self.role is not None and _RANK[self.role] >= _RANK[MINIMUM_ROLE[action]] + + def allowed_actions(self) -> list[BoardAction]: + return [action for action in BoardAction if self.allows(action)] + + +def item_allows(*, is_item_owner: bool, is_admin: bool, board: Optional[BoardAccess], action: ItemAction) -> bool: + """Whether a caller may do an item action, given the access they have to the board the item is on (None when + it is on none).""" + if is_item_owner or is_admin: + return True + board_action = _BOARD_ACTION_FOR_ITEM[action] + return board is not None and board_action is not None and board.allows(board_action) + + +@dataclass(frozen=True) +class BoardViewer: + """The account a board is described to.""" + + user_id: str + is_admin: bool + + +def queue_viewer(user_id: str, *, multiuser: bool, users: "UserServiceBase") -> Optional[BoardViewer]: + """The queue user as the boards API sees a caller, or None when the account may do nothing at queue time. + + In single-user mode the queue runs as an admin and the account is not looked up. In multiuser mode a deleted or + deactivated account keeps no queue-time privileges, however its item got past the processor's owner checks, and + an active one brings its own admin flag. Graph execution and workflow-call expansion both resolve the queue + user through here, so a refusal at queue time is the same refusal everywhere. + """ + if not multiuser: + return BoardViewer(user_id=user_id, is_admin=True) + user = users.get(user_id) + if user is None or not user.is_active: + return None + return BoardViewer(user_id=user_id, is_admin=user.is_admin) + + +@dataclass(frozen=True) +class BoardAudience: + """Who sees what lands on a board: everyone, or its owner and members (admins see every board).""" + + everyone: bool + user_ids: tuple[str, ...] + + +class BoardAccessDeniedError(Exception): + """The caller may not do the action to the board.""" + + def __init__(self, board_id: str, action: BoardAction) -> None: + super().__init__(f"Not allowed to {action.value} on board {board_id}") + self.board_id = board_id + self.action = action + + +class ItemAccessDeniedError(Exception): + """The caller may not do the action to the item.""" + + def __init__(self, item_name: str, action: ItemAction) -> None: + super().__init__(f"Not allowed to {action.value} {item_name}") + self.item_name = item_name + self.action = action diff --git a/invokeai/app/services/board_access/board_access_default.py b/invokeai/app/services/board_access/board_access_default.py new file mode 100644 index 00000000000..31f12c1ad38 --- /dev/null +++ b/invokeai/app/services/board_access/board_access_default.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Optional + +from invokeai.app.services.board_access.board_access_common import ( + BoardAccess, + BoardAccessDeniedError, + BoardAction, + BoardAudience, + BoardRole, + GeneralAccess, + ItemAccessDeniedError, + ItemAction, + item_allows, + role_of, +) +from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.queries import Queries + +if TYPE_CHECKING: + from invokeai.app.services.invoker import Invoker + + +class BoardAccessService: + """Decides what a caller may do to a board and to the images and videos on it. + + Routers, graph execution, workflow-call expansion and socket routing all ask here, so the rules in + `board_access_common` are applied the same way everywhere. Listings decide which boards a caller sees in SQL + (`queries/board_access.readable_board`), by the same rules. + """ + + __invoker: Invoker + + def __init__(self, database: Database) -> None: + self._database = database + + @property + def _queries(self) -> Queries: + # Looked up on use: `InvocationServices` builds this service for every caller, some of them with no + # database to query. + return self._database.queries + + def start(self, invoker: Invoker) -> None: + self.__invoker = invoker + + def get(self, board_id: str, user_id: str, is_admin: bool) -> BoardAccess: + """The caller's access to the board. Raises `BoardRecordNotFoundException` when there is no such board.""" + facts = self._queries.boards.access_facts(board_id, user_id) + if facts is None: + raise BoardRecordNotFoundException + owner_id, general_access, member_role, is_inbox = facts + role = role_of( + owner_id=owner_id, + user_id=user_id, + member_role=BoardRole(member_role) if member_role is not None else None, + general_access=GeneralAccess(general_access), + ) + return BoardAccess(role=role, is_admin=is_admin, is_inbox=is_inbox) + + def require(self, board_id: str, user_id: str, is_admin: bool, action: BoardAction) -> BoardAccess: + """The caller's access, when it allows the action. Raises `BoardAccessDeniedError` otherwise, and + `BoardRecordNotFoundException` when there is no such board.""" + access = self.get(board_id, user_id, is_admin) + if not access.allows(action): + raise BoardAccessDeniedError(board_id, action) + return access + + def member_roles(self, board_ids: list[str], user_id: str) -> dict[str, BoardRole]: + """The caller's membership role on each of these boards it is a member of, in one query.""" + return { + board_id: BoardRole(role) + for board_id, role in self._queries.boards.member_roles(board_ids, user_id).items() + } + + def require_image(self, image_name: str, user_id: str, is_admin: bool, action: ItemAction) -> None: + """Raises `ItemAccessDeniedError` unless the caller may do the action to the image.""" + if is_admin: + return + services = self.__invoker.services + is_owner = services.image_records.get_user_id(image_name) == user_id + board_id = None if is_owner else services.board_image_records.get_board_for_image(image_name) + self._require_item(image_name, board_id, user_id, is_admin, is_owner, action) + + def require_video(self, video_name: str, user_id: str, is_admin: bool, action: ItemAction) -> None: + """Raises `ItemAccessDeniedError` unless the caller may do the action to the video.""" + if is_admin: + return + services = self.__invoker.services + is_owner = services.video_records.get_user_id(video_name) == user_id + board_id = None if is_owner else services.board_video_records.get_board_for_video(video_name) + self._require_item(video_name, board_id, user_id, is_admin, is_owner, action) + + def _require_item( + self, + item_name: str, + board_id: Optional[str], + user_id: str, + is_admin: bool, + is_owner: bool, + action: ItemAction, + ) -> None: + board: Optional[BoardAccess] = None + if board_id is not None: + try: + board = self.get(board_id, user_id, is_admin) + except BoardRecordNotFoundException: + board = None + if not item_allows(is_item_owner=is_owner, is_admin=is_admin, board=board, action=action): + raise ItemAccessDeniedError(item_name, action) + + def visible_project_ids(self, project_ids: Iterable[str], user_id: str, is_admin: bool) -> set[str]: + """The project ids among these the caller may learn from the media made in them: all of them for an admin, + otherwise those naming the caller's own projects. One query, however many ids.""" + ids = set(project_ids) + if is_admin or not ids: + return ids + return self._queries.projects.owned_ids(user_id, sorted(ids)) + + def audience(self, board_id: str) -> Optional[BoardAudience]: + """Who sees what lands on the board, or None when there is no such board.""" + record = self._queries.boards.get(board_id) + if record is None: + return None + if GeneralAccess(record.general_access) is not GeneralAccess.Restricted: + return BoardAudience(everyone=True, user_ids=()) + members = self._queries.boards.shared_user_ids(board_id) + return BoardAudience(everyone=False, user_ids=(record.user_id, *members)) diff --git a/invokeai/app/services/board_records/board_records_base.py b/invokeai/app/services/board_records/board_records_base.py index 07dbb6fe037..dbba7da1d86 100644 --- a/invokeai/app/services/board_records/board_records_base.py +++ b/invokeai/app/services/board_records/board_records_base.py @@ -64,23 +64,11 @@ def update( Raises: BoardRecordInboxException: The board is a project's inbox and the changes rename, - archive, publish or move it. Only the cover may change through here. + archive, change the general access of or move it. Only the cover may change through here. BoardRecordProjectNotFoundException: The destination is not one of the owner's projects. - BoardRecordProjectUnavailableException: The board would be a non-private or explicitly - shared member of a project. """ pass - @abstractmethod - def is_board_shared_with_user(self, board_id: str, user_id: str) -> bool: - """Checks whether a board has been explicitly shared with a specific user.""" - pass - - @abstractmethod - def get_shared_user_ids(self, board_id: str) -> list[str]: - """Gets the ids of the users a board has been explicitly shared with.""" - pass - @abstractmethod def get_many( self, diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py index a487b30dbb7..bafa1af115c 100644 --- a/invokeai/app/services/board_records/board_records_common.py +++ b/invokeai/app/services/board_records/board_records_common.py @@ -2,22 +2,31 @@ from enum import Enum from typing import Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator +from invokeai.app.services.board_access.board_access_common import GeneralAccess from invokeai.app.util.metaenum import MetaEnum from invokeai.app.util.misc import get_iso_timestamp from invokeai.app.util.model_exclude_null import BaseModelExcludeNull class BoardVisibility(str, Enum, metaclass=MetaEnum): - """The visibility options for a board.""" + """Deprecated: a board's general access under its pre-7.0 name. Kept until 8.0 for older clients.""" Private = "private" - """Only the board owner (and admins) can see and modify this board.""" + """General access `restricted`.""" Shared = "shared" - """All users can view this board, but only the owner (and admins) can modify it.""" + """General access `viewer`.""" Public = "public" - """All users can view this board; only the owner (and admins) can modify its structure.""" + """General access `contributor`.""" + + +GENERAL_ACCESS_BY_VISIBILITY = { + BoardVisibility.Private: GeneralAccess.Restricted, + BoardVisibility.Shared: GeneralAccess.Viewer, + BoardVisibility.Public: GeneralAccess.Contributor, +} +VISIBILITY_BY_GENERAL_ACCESS = {access: visibility for visibility, access in GENERAL_ACCESS_BY_VISIBILITY.items()} class BoardRecord(BaseModelExcludeNull): @@ -39,16 +48,16 @@ class BoardRecord(BaseModelExcludeNull): """The name of the cover image of the board.""" archived: bool = Field(description="Whether or not the board is archived.") """Whether or not the board is archived.""" - board_visibility: BoardVisibility = Field( - default=BoardVisibility.Private, description="The visibility of the board." + general_access: GeneralAccess = Field( + default=GeneralAccess.Restricted, + description="The role every other account gets on the board: none (restricted), viewer or contributor.", ) - """The visibility of the board (private, shared, or public).""" project_id: Optional[str] = Field( default=None, description="The id of the owner's project this board belongs to; absent for a Library board." ) """A board lives in exactly one of its owner's projects, or in the Library when this is `None`. - Project boards are private and unshared. Clients must treat the field as absent-or-null: the - record's own `model_dump` drops `None` values, but a route's response serialization need not.""" + Clients must treat the field as absent-or-null: the record's own `model_dump` drops `None` values, + but a route's response serialization need not.""" def deserialize_board_record(board_dict: dict) -> BoardRecord: @@ -66,11 +75,10 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: deleted_at = board_dict.get("deleted_at", get_iso_timestamp()) archived = board_dict.get("archived", False) project_id = board_dict.get("project_id") - board_visibility_raw = board_dict.get("board_visibility", BoardVisibility.Private.value) try: - board_visibility = BoardVisibility(board_visibility_raw) + general_access = GeneralAccess(board_dict.get("general_access", GeneralAccess.Restricted.value)) except ValueError: - board_visibility = BoardVisibility.Private + general_access = GeneralAccess.Restricted return BoardRecord( board_id=board_id, @@ -81,7 +89,7 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: updated_at=updated_at, deleted_at=deleted_at, archived=archived, - board_visibility=board_visibility, + general_access=general_access, project_id=project_id, ) @@ -103,7 +111,14 @@ class BoardChanges(BaseModel, extra="forbid"): default=None, max_length=255, description="The name of the board's new cover image." ) archived: Optional[bool] = Field(default=None, description="Whether or not the board is archived") - board_visibility: Optional[BoardVisibility] = Field(default=None, description="The visibility of the board.") + general_access: Optional[GeneralAccess] = Field( + default=None, description="The role every other account gets on the board. Only its owner may change it." + ) + board_visibility: Optional[BoardVisibility] = Field( + default=None, + description="Deprecated, to be removed in 8.0: general access under its old name. Send `general_access`.", + json_schema_extra={"deprecated": True}, + ) project_id: Optional[str] = Field( default=None, description=( @@ -112,6 +127,16 @@ class BoardChanges(BaseModel, extra="forbid"): ), ) + @model_validator(mode="after") + def _visibility_names_general_access(self) -> "BoardChanges": + """`board_visibility` is `general_access` under its old name: only one of them may be sent.""" + if self.board_visibility is not None: + if self.general_access is not None: + raise ValueError("Send general_access or board_visibility, not both") + self.general_access = GENERAL_ACCESS_BY_VISIBILITY[self.board_visibility] + self.board_visibility = None + return self + @property def moves_board(self) -> bool: """Whether the request names a destination at all; `None` is the Library, absent is "stay".""" @@ -155,14 +180,3 @@ class BoardRecordProjectNotFoundException(BoardRecordSaveException): def __init__(self, message="Project not found"): super().__init__(message) - - -class BoardRecordProjectUnavailableException(BoardRecordSaveException): - """Raised when a board cannot be in a project: it is shared or public, or would become so. - - Boards in a project are private and unshared, so a visibility change and a move are refused - together whenever the result would be a non-private project board. - """ - - def __init__(self, message="Boards in a project must be private"): - super().__init__(message) diff --git a/invokeai/app/services/board_records/board_records_default.py b/invokeai/app/services/board_records/board_records_default.py index 593eda740f2..f7489df8845 100644 --- a/invokeai/app/services/board_records/board_records_default.py +++ b/invokeai/app/services/board_records/board_records_default.py @@ -8,9 +8,7 @@ BoardRecordNotFoundException, BoardRecordOrderBy, BoardRecordProjectNotFoundException, - BoardRecordProjectUnavailableException, BoardRecordSaveException, - BoardVisibility, ) from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries import Queries @@ -74,12 +72,6 @@ def get( raise BoardRecordNotFoundException return board - def is_board_shared_with_user(self, board_id: str, user_id: str) -> bool: - return self._queries.boards.is_shared_with(board_id, user_id) - - def get_shared_user_ids(self, board_id: str) -> list[str]: - return self._queries.boards.shared_user_ids(board_id) - def update( self, board_id: str, @@ -102,16 +94,10 @@ def apply(q: Queries) -> Optional[BoardRecord]: if inbox_project is not None and ( changes.board_name is not None or changes.archived is not None - or changes.board_visibility is not None + or changes.general_access is not None or changes.moves_board ): raise BoardRecordInboxException - next_project = changes.project_id if changes.moves_board else current.project_id - next_visibility = changes.board_visibility or current.board_visibility - if next_project is not None and ( - next_visibility != BoardVisibility.Private or q.boards.is_shared(board_id) - ): - raise BoardRecordProjectUnavailableException if not destination_exists: raise BoardRecordProjectNotFoundException if not q.boards.update(board_id, changes): diff --git a/invokeai/app/services/boards/boards_base.py b/invokeai/app/services/boards/boards_base.py index 496ba5f29cd..850760ba69d 100644 --- a/invokeai/app/services/boards/boards_base.py +++ b/invokeai/app/services/boards/boards_base.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import Optional +from invokeai.app.services.board_access.board_access_common import BoardViewer from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.shared.pagination import OffsetPaginatedResults, SQLiteDirection @@ -15,16 +16,20 @@ def create( board_name: str, user_id: str, project_id: Optional[str] = None, + *, + viewer: BoardViewer, ) -> BoardDTO: - """Creates a board for a specific user, in one of their projects or in the Library.""" + """Creates a board for a specific user, in one of their projects or in the Library, described to the + viewer (their role, allowed actions, the owner's name).""" pass @abstractmethod def get_dto( self, board_id: str, + viewer: BoardViewer, ) -> BoardDTO: - """Gets a board.""" + """Gets a board, described to the viewer.""" pass @abstractmethod @@ -32,8 +37,9 @@ def update( self, board_id: str, changes: BoardChanges, + viewer: BoardViewer, ) -> BoardDTO: - """Updates a board.""" + """Updates a board, returning it described to the viewer.""" pass @abstractmethod diff --git a/invokeai/app/services/boards/boards_common.py b/invokeai/app/services/boards/boards_common.py index 7a1ceda82eb..1bbbdad5534 100644 --- a/invokeai/app/services/boards/boards_common.py +++ b/invokeai/app/services/boards/boards_common.py @@ -2,7 +2,12 @@ from pydantic import Field -from invokeai.app.services.board_records.board_records_common import BoardRecord +from invokeai.app.services.board_access.board_access_common import BoardAction, BoardRole +from invokeai.app.services.board_records.board_records_common import ( + VISIBILITY_BY_GENERAL_ACCESS, + BoardRecord, + BoardVisibility, +) class BoardDTO(BoardRecord): @@ -26,8 +31,27 @@ class BoardDTO(BoardRecord): """Uploaded videos are assets ('user' category) while generated videos are 'general', mirroring images. `video_count` stays the total so "delete board with N videos" copy remains honest; this field lets clients split the total across the Media/Assets views.""" - owner_username: Optional[str] = Field(default=None, description="The username of the board owner (for admin view).") - """The username of the board owner (for admin view).""" + owner_username: Optional[str] = Field( + default=None, + description=( + "The board owner's display name. Admins get the email of an owner without one; other callers get" + " 'Unknown user'. Null when the owner's account no longer exists." + ), + ) + board_visibility: BoardVisibility = Field( + default=BoardVisibility.Private, + description="Deprecated, to be removed in 8.0: `general_access` under its old name.", + json_schema_extra={"deprecated": True}, + ) + my_role: Optional[BoardRole] = Field( + default=None, description="The caller's own role on the board; null when only admin rights give access." + ) + allowed_actions: list[BoardAction] = Field( + default_factory=list, description="The board actions the caller may take. Gate interfaces on these." + ) + admin_override: bool = Field( + default=False, description="Whether admin rights give the caller more than `my_role` does." + ) is_inbox: bool = Field( default=False, description="Whether this board is its project's inbox, which only the project APIs may change." ) @@ -49,9 +73,11 @@ def board_record_to_dto( asset_video_count: int = 0, is_inbox: bool = False, ) -> BoardDTO: - """Converts a board record to a board DTO.""" + """Converts a board record to a board DTO, describing it to no one in particular: `BoardService` adds what + depends on the caller.""" return BoardDTO( **board_record.model_dump(exclude={"cover_image_name"}), + board_visibility=VISIBILITY_BY_GENERAL_ACCESS[board_record.general_access], cover_image_name=cover_image_name, cover_video_name=cover_video_name, image_count=image_count, diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py index 9ebf1098697..d8dd01c00dd 100644 --- a/invokeai/app/services/boards/boards_default.py +++ b/invokeai/app/services/boards/boards_default.py @@ -1,5 +1,6 @@ from typing import Optional +from invokeai.app.services.board_access.board_access_common import BoardAccess, BoardRole, BoardViewer, role_of from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord, BoardRecordOrderBy from invokeai.app.services.boards.boards_base import BoardServiceABC from invokeai.app.services.boards.boards_common import BoardDTO, board_record_to_dto @@ -49,18 +50,19 @@ def create( board_name: str, user_id: str, project_id: Optional[str] = None, + *, + viewer: BoardViewer, ) -> BoardDTO: board_record = self.__invoker.services.board_records.save(board_name, user_id, project_id) - return board_record_to_dto(board_record, None, 0, 0) + return self._describe([board_record_to_dto(board_record, None, 0, 0)], viewer)[0] - def get_dto(self, board_id: str) -> BoardDTO: - # One query for the record and the project it is the inbox of. `get_dto` is what every - # authorization check resolves through, so a second lookup here is a second transaction — - # and the lock it takes — on the most-travelled read in the API. + def get_dto(self, board_id: str, viewer: BoardViewer) -> BoardDTO: + # One query for the record and the project it is the inbox of: a second lookup would be a second + # transaction, and the lock it takes, on a read every board route makes. board_record, inbox_of_project_id = self.__invoker.services.board_records.get_with_inbox_project(board_id) cover_image_name, cover_video_name = self._resolve_cover(board_record.board_id) image_count, video_count, asset_count, asset_video_count = self._get_counts(board_id) - return board_record_to_dto( + dto = board_record_to_dto( board_record, cover_image_name, image_count, @@ -70,17 +72,14 @@ def get_dto(self, board_id: str) -> BoardDTO: asset_video_count=asset_video_count, is_inbox=inbox_of_project_id is not None, ) + return self._describe([dto], viewer)[0] - def update( - self, - board_id: str, - changes: BoardChanges, - ) -> BoardDTO: + def update(self, board_id: str, changes: BoardChanges, viewer: BoardViewer) -> BoardDTO: self.__invoker.services.board_records.update(board_id, changes) # Re-read through `get_dto` rather than shaping the update's own return: it is the one # place that resolves cover, counts and the inbox flag together, and an update that # dropped the flag would tell the gallery a project's inbox is an ordinary board. - return self.get_dto(board_id) + return self.get_dto(board_id, viewer) def delete_if_unclaimed(self, board_id: str) -> bool: return self.__invoker.services.board_records.delete_if_unclaimed(board_id) @@ -98,7 +97,7 @@ def get_many( board_records = self.__invoker.services.board_records.get_many( user_id, is_admin, order_by, direction, offset, limit, include_archived ) - board_dtos = self._to_dtos(board_records.items, is_admin) + board_dtos = self._to_dtos(board_records.items, BoardViewer(user_id=user_id, is_admin=is_admin)) return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos)) @@ -113,46 +112,63 @@ def get_all( board_records = self.__invoker.services.board_records.get_all( user_id, is_admin, order_by, direction, include_archived ) - return self._to_dtos(board_records, is_admin) - - def _to_dtos(self, board_records: list[BoardRecord], is_admin: bool) -> list[BoardDTO]: - """Builds board DTOs for a listing with a fixed number of queries. + return self._to_dtos(board_records, BoardViewer(user_id=user_id, is_admin=is_admin)) - Both the media summaries and (for admins) the owner display names are fetched for - the whole page at once. The owner lookup used to run one `users.get` per board, so - an admin listing 50 boards issued 50 extra queries for what is usually a handful of - distinct owners. - """ + def _to_dtos(self, board_records: list[BoardRecord], viewer: BoardViewer) -> list[BoardDTO]: + """Builds the board DTOs of a listing with a fixed number of queries: the media summaries and inbox flags + for the whole page at once, then what `_describe` adds.""" summaries = self.__invoker.services.gallery.get_board_media_summaries( [record.board_id for record in board_records] ) inbox_board_ids = self.__invoker.services.board_records.get_inbox_board_ids( [record.board_id for record in board_records] ) - owners = ( - self.__invoker.services.users.get_many([record.user_id for record in board_records]) if is_admin else {} - ) - - board_dtos: list[BoardDTO] = [] - for r in board_records: - summary = summaries[r.board_id] - - # For admin users, include owner username - owner = owners.get(r.user_id) - owner_username = (owner.display_name or owner.email) if owner else None - - board_dtos.append( - board_record_to_dto( - r, - summary.cover_image_name, - summary.image_count, - summary.asset_count, - owner_username, - cover_video_name=summary.cover_video_name, - video_count=summary.video_count, - asset_video_count=summary.asset_video_count, - is_inbox=r.board_id in inbox_board_ids, + dtos = [ + board_record_to_dto( + r, + summaries[r.board_id].cover_image_name, + summaries[r.board_id].image_count, + summaries[r.board_id].asset_count, + cover_video_name=summaries[r.board_id].cover_video_name, + video_count=summaries[r.board_id].video_count, + asset_video_count=summaries[r.board_id].asset_video_count, + is_inbox=r.board_id in inbox_board_ids, + ) + for r in board_records + ] + return self._describe(dtos, viewer) + + def _describe(self, dtos: list[BoardDTO], viewer: BoardViewer) -> list[BoardDTO]: + """Adds what depends on who is looking: their role and allowed actions, the owner's name, and the project, + which only the owner and admins learn. Two queries for any number of boards.""" + board_ids = [dto.board_id for dto in dtos] + member_roles = self.__invoker.services.board_access.member_roles(board_ids, viewer.user_id) + owners = self.__invoker.services.users.get_many([dto.user_id for dto in dtos]) + described: list[BoardDTO] = [] + for dto in dtos: + role = role_of( + owner_id=dto.user_id, + user_id=viewer.user_id, + member_role=member_roles.get(dto.board_id), + general_access=dto.general_access, + ) + access = BoardAccess(role=role, is_admin=viewer.is_admin, is_inbox=dto.is_inbox) + owner = owners.get(dto.user_id) + owner_username = None + if owner is not None: + # An email only for admins and the owner themselves. + sees_email = viewer.is_admin or owner.user_id == viewer.user_id + owner_username = owner.display_name or (owner.email if sees_email else "Unknown user") + sees_project = viewer.is_admin or role is BoardRole.Owner + described.append( + dto.model_copy( + update={ + "my_role": role, + "allowed_actions": access.allowed_actions(), + "admin_override": access.admin_override, + "owner_username": owner_username, + "project_id": dto.project_id if sees_project else None, + } ) ) - - return board_dtos + return described diff --git a/invokeai/app/services/events/events_base.py b/invokeai/app/services/events/events_base.py index 95f3d67dd05..d305374bfda 100644 --- a/invokeai/app/services/events/events_base.py +++ b/invokeai/app/services/events/events_base.py @@ -53,7 +53,6 @@ if TYPE_CHECKING: from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput - from invokeai.app.services.board_records.board_records_common import BoardRecord from invokeai.app.services.download.download_base import DownloadJob from invokeai.app.services.images.images_common import ImageDTO from invokeai.app.services.intermediates.intermediates_common import IntermediatesOperation @@ -395,38 +394,26 @@ def emit_intermediates_operation_changed(self, operation: "IntermediatesOperatio # region Media uploads - def emit_image_uploaded( - self, image_dto: "ImageDTO", user_id: str, board: Optional["BoardRecord"], shared_user_ids: list[str] - ) -> None: - """Emitted when a gallery-visible image arrives through the upload API; `board` is the one it was sent to""" + def emit_image_uploaded(self, image_dto: "ImageDTO", user_id: str) -> None: + """Emitted when a gallery-visible image arrives through the upload API""" # The board attach can fail quietly inside create(); the DTO says where the image actually is. - board = board if image_dto.board_id is not None else None self.dispatch( ImageUploadedEvent.build( image_name=image_dto.image_name, image_category=image_dto.image_category, user_id=user_id, board_id=image_dto.board_id, - board_owner_id=board.user_id if board else None, - board_visibility=board.board_visibility if board else None, - shared_user_ids=shared_user_ids if board else [], ) ) - def emit_video_uploaded( - self, video_dto: "VideoDTO", user_id: str, board: Optional["BoardRecord"], shared_user_ids: list[str] - ) -> None: - """Emitted when a gallery-visible video arrives through the upload API; `board` is the one it was sent to""" - board = board if video_dto.board_id is not None else None + def emit_video_uploaded(self, video_dto: "VideoDTO", user_id: str) -> None: + """Emitted when a gallery-visible video arrives through the upload API""" self.dispatch( VideoUploadedEvent.build( video_name=video_dto.video_name, video_category=video_dto.video_category, user_id=user_id, board_id=video_dto.board_id, - board_owner_id=board.user_id if board else None, - board_visibility=board.board_visibility if board else None, - shared_user_ids=shared_user_ids if board else [], ) ) diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py index c1605cc9aab..f70056a4f32 100644 --- a/invokeai/app/services/events/events_common.py +++ b/invokeai/app/services/events/events_common.py @@ -4,7 +4,6 @@ from fastapi_events.registry.payload_schema import registry as payload_schema from pydantic import BaseModel, ConfigDict, Field, model_validator -from invokeai.app.services.board_records.board_records_common import BoardVisibility from invokeai.app.services.image_records.image_records_common import ImageCategory from invokeai.app.services.intermediates.intermediates_common import IntermediatesOperation from invokeai.app.services.model_install.model_install_common import ModelInstallJob, ModelSource @@ -1098,16 +1097,6 @@ class MediaUploadedEventBase(EventBase): user_id: str = Field(description="The user who uploaded the media") board_id: Optional[str] = Field(default=None, description="The board the media was added to, if any") - board_owner_id: Optional[str] = Field( - default=None, description="The owner of that board, who may differ from the uploader; None for no board" - ) - board_visibility: Optional[BoardVisibility] = Field( - default=None, description="The visibility of that board; None when the media landed on no board" - ) - shared_user_ids: list[str] = Field( - default_factory=list, - description="Users a private board is explicitly shared with, who can see this media too", - ) @payload_schema.register @@ -1126,18 +1115,12 @@ def build( image_category: ImageCategory, user_id: str, board_id: Optional[str], - board_owner_id: Optional[str], - board_visibility: Optional[BoardVisibility], - shared_user_ids: list[str], ) -> "ImageUploadedEvent": return cls( image_name=image_name, image_category=image_category, user_id=user_id, board_id=board_id, - board_owner_id=board_owner_id, - board_visibility=board_visibility, - shared_user_ids=shared_user_ids, ) @@ -1157,18 +1140,12 @@ def build( video_category: ImageCategory, user_id: str, board_id: Optional[str], - board_owner_id: Optional[str], - board_visibility: Optional[BoardVisibility], - shared_user_ids: list[str], ) -> "VideoUploadedEvent": return cls( video_name=video_name, video_category=video_category, user_id=user_id, board_id=board_id, - board_owner_id=board_owner_id, - board_visibility=board_visibility, - shared_user_ids=shared_user_ids, ) diff --git a/invokeai/app/services/image_index/image_index_records_base.py b/invokeai/app/services/image_index/image_index_records_base.py index 24cceb5238d..b5ce134960d 100644 --- a/invokeai/app/services/image_index/image_index_records_base.py +++ b/invokeai/app/services/image_index/image_index_records_base.py @@ -93,7 +93,7 @@ def list_accessible_embedded_items(self, user_id: str | None, model_id: str) -> A user can access their own unboarded items, items on boards they own, items on shared or public boards, and items on boards individually shared with them - (shared_boards). Pass user_id=None for the admin scope. + (board members). Pass user_id=None for the admin scope. Items on an archived board are excluded from every scope, including the admin one, matching the gallery's "all" listing. So the admin scope is every embedded item that diff --git a/invokeai/app/services/intermediates/intermediates_common.py b/invokeai/app/services/intermediates/intermediates_common.py index f1402fafabf..33cadfd48c3 100644 --- a/invokeai/app/services/intermediates/intermediates_common.py +++ b/invokeai/app/services/intermediates/intermediates_common.py @@ -159,7 +159,8 @@ class IntermediatesAffectedDocument(BaseModel): kind: MediaReferenceOwnerKind = Field( description=( - "`client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair" + "`project_workspace` is one account's workspace in a project; `client_state` is the legacy editor's" + " persisted state; `quarantined_project` is a project kept for repair" ) ) user_id: str diff --git a/invokeai/app/services/intermediates/intermediates_records_default.py b/invokeai/app/services/intermediates/intermediates_records_default.py index 64fd8027290..f990ecfea57 100644 --- a/invokeai/app/services/intermediates/intermediates_records_default.py +++ b/invokeai/app/services/intermediates/intermediates_records_default.py @@ -449,7 +449,7 @@ def get_document_names(self, owners: Collection[ReferenceOwner]) -> dict[Referen """Names of the referencing documents, one query per kind; client state has none.""" wanted: dict[str, set[ReferenceOwner]] = defaultdict(set) for owner in owners: - if owner.owner_kind in ("project", "quarantined_project", "workflow"): + if owner.owner_kind in ("project", "project_workspace", "quarantined_project", "workflow"): wanted[owner.owner_kind].add(owner) def names(q: Queries) -> dict[ReferenceOwner, str]: @@ -462,7 +462,8 @@ def names(q: Queries) -> dict[ReferenceOwner, str]: if name is not None } for owner in kind_owners: - name = named.get((None if owner_kind == "workflow" else owner.user_id, owner.owner_id)) + unowned = owner_kind in ("workflow", "project_workspace") + name = named.get((None if unowned else owner.user_id, owner.owner_id)) if name is not None: found[owner] = name return found diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 41de3b8d704..f5416b02bb6 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from invokeai.app.services.board_access.board_access_default import BoardAccessService from invokeai.app.services.object_serializer.object_serializer_base import ObjectSerializerBase from invokeai.app.services.style_preset_images.style_preset_images_base import StylePresetImageFileStorageBase from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase @@ -116,6 +117,7 @@ def __init__( progress_previews: "ProgressPreviewsBase | None" = None, fonts: "FontService | None" = None, gallery_maintenance: "GalleryMaintenanceService | None" = None, + board_access: "BoardAccessService | None" = None, ): self.board_images = board_images self.board_image_records = board_image_records @@ -168,3 +170,6 @@ def __init__( self.fonts = fonts self.intermediates = intermediates self.gallery_maintenance = gallery_maintenance + # Stateless over the database, so a real one is the default: no caller should get looser rules by leaving it + # out. + self.board_access = board_access or BoardAccessService(database) diff --git a/invokeai/app/services/project_records/project_records_base.py b/invokeai/app/services/project_records/project_records_base.py index faf1a95b5d2..13743245b1f 100644 --- a/invokeai/app/services/project_records/project_records_base.py +++ b/invokeai/app/services/project_records/project_records_base.py @@ -1,11 +1,14 @@ from abc import ABC, abstractmethod -from typing import Any, Literal +from typing import Any, Literal, Optional from invokeai.app.services.project_records.project_records_common import ( DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, ProjectBoardSnapshotDTO, - ProjectRecordDTO, ProjectSummaryDTO, + ProjectUpdateWorkspaceRequest, + ProjectWithWorkspaceDTO, + ProjectWorkspaceDTO, ) @@ -21,6 +24,10 @@ class ProjectRecordsStorageBase(ABC): always commit together. The project's other boards are ordinary boards whose `project_id` names it; they are created and moved through the board service and only leave with the project, released to the Library or deleted. + + From document schema 4 a project is two documents: its content, which the + project saves, and each member's workspace in it, saved apart with a revision + of its own. The storage parses neither beyond the media they name. """ @abstractmethod @@ -33,8 +40,16 @@ def create( board_id: str | None = None, minimum_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, - ) -> ProjectRecordDTO: - """Create a project for the user, with its inbox. + minimum_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + workspace: Optional[dict[str, Any]] = None, + ) -> ProjectWithWorkspaceDTO: + """Create a project for the user, with its inbox and, when `workspace` is given, the user's workspace in + it, in the same transaction. + + The workspace is checked as `save_workspace` checks it, except that `max_canvas_schema_version` covers it, + and it is always the user's first row in the project. A refused create leaves no project, board, workspace + or media reference behind. Args: user_id: The owning user. @@ -47,17 +62,24 @@ def create( import. minimum_canvas_schema_version: Compatibility floor stored with the project. max_canvas_schema_version: Newest canvas schema understood by the caller. + minimum_document_schema_version: Document schema floor stored with the project. + max_document_schema_version: Newest document schema understood by the caller. + workspace: The user's first workspace in the project, stored with the content; omitted to create the + project without one. Returns: - The created project record. + The created project record, with the workspace this create stored, or None when it carried none. Raises: ProjectRecordExistsError: The user already has a project with this id. + ProjectWorkspaceBeforeSplitError: `workspace` is given, but `minimum_document_schema_version` is below + the split schema, where a project is content and workspaces. ProjectBoardNotFoundError: `board_id` is missing or belongs to another user. ProjectBoardUnavailableError: The board is public, shared, or already claimed. ProjectCanvasSchemaUnsupportedError: The caller cannot safely edit the requested schema. - ProjectDocumentInvalidError: The document is not standards-compliant UTF-8 JSON. - ProjectDocumentTooLargeError: The project document exceeds the storage limit. + ProjectDocumentSchemaUnsupportedError: The caller does not understand the requested document schema. + ProjectDocumentInvalidError: The document or the workspace is not standards-compliant UTF-8 JSON. + ProjectDocumentTooLargeError: The project document or the workspace exceeds the storage limit. """ pass @@ -67,12 +89,14 @@ def get( user_id: str, project_id: str, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, - ) -> ProjectRecordDTO: - """Get one of the user's projects, including its document. + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ) -> ProjectWithWorkspaceDTO: + """Get one of the user's projects: its content, and the user's workspace in it if they have saved one. Raises: ProjectRecordNotFoundError: No such project for this user. ProjectCanvasSchemaUnsupportedError: The caller cannot safely edit the stored schema. + ProjectDocumentSchemaUnsupportedError: The caller does not understand the stored document schema. """ pass @@ -91,21 +115,60 @@ def update( data: dict[str, Any], minimum_canvas_schema_version: int | None = None, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, - ) -> ProjectRecordDTO: - """Save a project if the caller's revision is current, renaming its inbox to match. - - A save that loses the revision race renames nothing. + minimum_document_schema_version: int | None = None, + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + workspace: Optional[ProjectUpdateWorkspaceRequest] = None, + ) -> ProjectWithWorkspaceDTO: + """Save a project's content if the caller's revision is current, renaming its inbox to match, and with it, + when `workspace` is given, the caller's workspace in the project, in the same transaction. + + A save that loses either revision race writes nothing. The workspace is checked as `save_workspace` checks + it, except that the content's `max_canvas_schema_version` covers it. The result carries the workspace this + save stored, or None when it carried none. The save that raises the document floor across the split must + carry the workspace: a split that landed its content alone would leave the caller's settings to a default. Raises: ProjectRecordNotFoundError: No such project for this user. ProjectRecordConflictError: The stored revision differs from expected_revision. + ProjectWorkspaceConflictError: The stored workspace revision (or its absence) differs from the expected. + ProjectWorkspaceBeforeSplitError: `workspace` is given, but the save leaves the document floor below the + split schema. + ProjectSplitNeedsWorkspaceError: The save raises the document floor across the split without `workspace`. ProjectCanvasSchemaUnsupportedError: The caller cannot safely edit the stored or requested schema. ProjectCanvasSchemaDowngradeError: The save tries to lower the stored compatibility floor. + ProjectDocumentSchemaUnsupportedError: The caller does not understand the stored or requested document + schema. + ProjectDocumentSchemaDowngradeError: The save tries to lower the stored document schema floor. ProjectDocumentInvalidError: The document is not standards-compliant UTF-8 JSON. ProjectDocumentTooLargeError: The project document exceeds the storage limit. """ pass + @abstractmethod + def save_workspace( + self, + user_id: str, + project_id: str, + expected_revision: Optional[int], + data: dict[str, Any], + max_document_schema_version: int, + max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + ) -> ProjectWorkspaceDTO: + """Save the user's workspace in one of their projects if `expected_revision` is its current revision, or + None and they have none yet. Independent of the content's revision: neither half's save conflicts with the + other's. The workspace holds canvas state (the staging area, the selection), so the project's canvas floor + applies to it as to the content. + + Raises: + ProjectRecordNotFoundError: No such project for this user. + ProjectWorkspaceConflictError: The stored workspace revision (or its absence) differs from the expected. + ProjectCanvasSchemaUnsupportedError: The caller cannot safely edit the project's canvas schema. + ProjectDocumentSchemaUnsupportedError: The caller does not understand the project's document schema. + ProjectDocumentInvalidError: The workspace is not standards-compliant UTF-8 JSON. + ProjectDocumentTooLargeError: The workspace exceeds the storage limit. + """ + pass + @abstractmethod def delete(self, user_id: str, project_id: str, boards: Literal["release", "delete"] = "release") -> None: """Delete one of the user's projects and its inbox, in one transaction. diff --git a/invokeai/app/services/project_records/project_records_common.py b/invokeai/app/services/project_records/project_records_common.py index 2b0a173bce9..aa7e532a9d0 100644 --- a/invokeai/app/services/project_records/project_records_common.py +++ b/invokeai/app/services/project_records/project_records_common.py @@ -1,6 +1,6 @@ """Common types and errors for the project records service.""" -from typing import Any, Literal +from typing import Any, Literal, Optional from pydantic import BaseModel, Field @@ -14,6 +14,21 @@ """ +DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION = 3 +"""The document schema floor of projects saved before the floor existed, and the newest schema a client that does +not say is assumed to understand. + +Schema 4 split the document into the project content, in `projects.data`, and each member's workspace, in +`project_workspaces`. A client that does not name the newest schema it understands predates the split, so it is +refused a split project rather than handed half of one. +""" + +SPLIT_PROJECT_DOCUMENT_SCHEMA_VERSION = 4 +"""The first document schema in which a project is content and workspaces. A workspace is stored only beside content +at this floor or above: beside a whole document, a build from before the split could still save the whole project +over it, and nothing would read the workspace.""" + + class ProjectRecordNotFoundError(Exception): """Raised when a project record is not found for the requesting user.""" @@ -22,7 +37,7 @@ def __init__(self, project_id: str) -> None: class ProjectRecordExistsError(Exception): - """Raised when creating a project with an id the user already has.""" + """Raised when creating a project with an id a project already has: ids are unique across accounts.""" def __init__(self, project_id: str) -> None: super().__init__(f"Project {project_id} already exists") @@ -71,6 +86,66 @@ def __init__(self, project_id: str, minimum_version: int, client_maximum_version ) +class ProjectDocumentSchemaUnsupportedError(Exception): + """Raised before a client may read or write a project whose document schema it does not understand.""" + + def __init__(self, project_id: str, minimum_version: int, client_maximum_version: int) -> None: + self.project_id = project_id + self.minimum_version = minimum_version + self.client_maximum_version = client_maximum_version + super().__init__( + f"Project {project_id} requires document schema {minimum_version}, but this client supports up to" + f" schema {client_maximum_version}. Update the client before opening or editing this project." + ) + + +class ProjectDocumentSchemaDowngradeError(Exception): + """Raised when a save tries to lower a project's document schema floor.""" + + def __init__(self, project_id: str, current_version: int, requested_version: int) -> None: + self.project_id = project_id + self.current_version = current_version + self.requested_version = requested_version + super().__init__( + f"Project {project_id} requires document schema {current_version}; its floor cannot be lowered to" + f" schema {requested_version}" + ) + + +class ProjectWorkspaceConflictError(Exception): + """Raised when a workspace save is based on a revision other than the stored one (another tab or device saved + first), or expects a workspace where there is none or none where there is one.""" + + def __init__(self, project_id: str, expected_revision: Optional[int], current_revision: Optional[int]) -> None: + self.current_revision = current_revision + super().__init__( + f"The workspace in project {project_id} is at revision {current_revision}; the save expected revision" + f" {expected_revision}" + ) + + +class ProjectWorkspaceBeforeSplitError(Exception): + """Raised when a save carries a workspace while leaving the project's document floor below the split.""" + + def __init__(self, project_id: str, document_floor: int) -> None: + self.document_floor = document_floor + super().__init__( + f"Project {project_id} would stay at document schema {document_floor}; a workspace is saved only with" + f" content at schema {SPLIT_PROJECT_DOCUMENT_SCHEMA_VERSION} or newer" + ) + + +class ProjectSplitNeedsWorkspaceError(Exception): + """Raised when a save raises a project's document floor across the split without the caller's workspace.""" + + def __init__(self, project_id: str, document_floor: int) -> None: + self.document_floor = document_floor + super().__init__( + f"Project {project_id} is held whole at document schema {document_floor}; the save that splits it into" + f" content at schema {SPLIT_PROJECT_DOCUMENT_SCHEMA_VERSION} or newer must carry the caller's workspace" + ) + + class ProjectCanvasSchemaDowngradeError(Exception): """Raised when a save tries to lower a project's compatibility floor.""" @@ -134,14 +209,57 @@ class ProjectSummaryDTO(BaseModel): minimum_canvas_schema_version: int = Field( ge=1, description="Oldest canvas schema a client must support before it may read or write this project" ) + minimum_document_schema_version: int = Field( + ge=1, + description="Oldest project document schema a client must support before it may read or write this project", + ) created_at: str = Field(description="When the project was created") - updated_at: str = Field(description="When the project was last saved") + updated_at: str = Field(description="When the project content was last saved") + workspace_updated_at: Optional[str] = Field( + default=None, description="When the caller last saved their workspace in the project; null if they have none" + ) class ProjectRecordDTO(ProjectSummaryDTO): """Full project record including the client-owned document.""" - data: dict[str, Any] = Field(description="The opaque client-owned project document") + data: dict[str, Any] = Field(description="The opaque client-owned project content (document schema 4 on)") + + +class ProjectWorkspaceDTO(BaseModel): + """One account's workspace in a project: how they work on it (layout, widgets, generation settings).""" + + data: dict[str, Any] = Field(description="The opaque client-owned workspace document") + revision: int = Field(description="Monotonic revision, incremented on every save of this workspace") + updated_at: str = Field(description="When the workspace was last saved") + + +class ProjectWithWorkspaceDTO(ProjectRecordDTO): + """A project's content together with the caller's workspace in it.""" + + workspace: Optional[ProjectWorkspaceDTO] = Field( + description=( + "The caller's workspace. On a read, null until they save one, when the client builds a default; on a" + " save, the workspace the save carried and stored, or null when it carried none" + ) + ) + + +class ProjectUpdateWorkspaceRequest(BaseModel): + """The caller's workspace, saved in the same transaction as the project content it accompanies. + + Only one client needs it: one that splits a whole project (document schema 3 or older) into content and + workspace. The raised floor, the content and the member's first workspace row then land together or not at all. + The save must leave the document floor at the split schema or above, or it is refused whole. + Landed by half -- the content stored, the workspace not -- the project would join every later load with a + default workspace, and the member's layout, settings, history and staging would be gone for good. + """ + + data: dict[str, Any] = Field(description="The opaque client-owned workspace document") + expected_revision: Optional[int] = Field( + description="The workspace revision this save is based on, or null when the caller has no workspace in the" + " project yet; a mismatch refuses the whole save, content included" + ) class ProjectBoardItemDTO(BaseModel): diff --git a/invokeai/app/services/project_records/project_records_default.py b/invokeai/app/services/project_records/project_records_default.py index b95574864e3..fa0ea836d00 100644 --- a/invokeai/app/services/project_records/project_records_default.py +++ b/invokeai/app/services/project_records/project_records_default.py @@ -2,12 +2,15 @@ import uuid from typing import Any, Literal, Optional -from invokeai.app.services.board_records.board_records_common import BOARD_NAME_MAX_LENGTH, BoardVisibility +from invokeai.app.services.board_access.board_access_common import GeneralAccess +from invokeai.app.services.board_records.board_records_common import BOARD_NAME_MAX_LENGTH from invokeai.app.services.project_records.project_records_base import ProjectRecordsStorageBase from invokeai.app.services.project_records.project_records_common import ( DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, PROJECT_BOARD_SNAPSHOT_MAX_ITEMS, PROJECT_DOCUMENT_MAX_BYTES, + SPLIT_PROJECT_DOCUMENT_SCHEMA_VERSION, ProjectBoardNotFoundError, ProjectBoardSnapshotDTO, ProjectBoardTooLargeError, @@ -15,18 +18,26 @@ ProjectCanvasSchemaDowngradeError, ProjectCanvasSchemaUnsupportedError, ProjectDocumentInvalidError, + ProjectDocumentSchemaDowngradeError, + ProjectDocumentSchemaUnsupportedError, ProjectDocumentTooLargeError, ProjectRecordConflictError, ProjectRecordDTO, ProjectRecordExistsError, ProjectRecordNotFoundError, + ProjectSplitNeedsWorkspaceError, ProjectSummaryDTO, + ProjectUpdateWorkspaceRequest, + ProjectWithWorkspaceDTO, + ProjectWorkspaceBeforeSplitError, + ProjectWorkspaceConflictError, + ProjectWorkspaceDTO, ) from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.errors import UniqueViolation from invokeai.app.services.shared.database.queries import Queries from invokeai.app.services.shared.database.queries.locks import DatabaseLock -from invokeai.app.services.shared.media_references import extract_media_references +from invokeai.app.services.shared.media_references import MediaReferences, extract_media_references from invokeai.app.util.misc import uuid_string @@ -52,6 +63,57 @@ def _require_supported_schema(*, project_id: str, minimum_version: int, client_m ) +def _require_supported_document_schema(*, project_id: str, minimum_version: int, client_maximum_version: int) -> None: + if client_maximum_version < minimum_version: + raise ProjectDocumentSchemaUnsupportedError( + project_id=project_id, + minimum_version=minimum_version, + client_maximum_version=client_maximum_version, + ) + + +def _next_document_floor(project_id: str, current: int, requested: Optional[int]) -> int: + """The document schema floor a save leaves: the requested one, which may only raise it, or the current one.""" + if requested is None: + return current + if requested < current: + raise ProjectDocumentSchemaDowngradeError( + project_id=project_id, current_version=current, requested_version=requested + ) + return requested + + +def _write_workspace( + q: Queries, + *, + user_id: str, + project_id: str, + expected_revision: Optional[int], + document_json: str, + references: MediaReferences, +) -> tuple[int, str]: + """Writes the account's workspace in a project whose row the caller has locked: its first row when + `expected_revision` is None, else its next revision. Returns the stamp (revision, saved at) it leaves. + + The project's row is the lock for its workspaces too: every workspace write takes it first, so the revision + read here is the one this write replaces, and a deletion of the project waits for it. + """ + current = q.projects.workspace_stamp(user_id, project_id) + current_revision = current[0] if current is not None else None + if current_revision != expected_revision: + raise ProjectWorkspaceConflictError(project_id, expected_revision, current_revision) + if current_revision is None: + q.projects.insert_workspace(user_id=user_id, project_id=project_id, data=document_json) + else: + q.projects.save_workspace(user_id=user_id, project_id=project_id, data=document_json) + q.media_references.replace( + owner_kind="project_workspace", user_id=user_id, owner_id=project_id, references=references + ) + stamp = q.projects.workspace_stamp(user_id, project_id) + assert stamp is not None, "the workspace was written in this transaction" + return stamp + + def _record(summary: Optional[ProjectSummaryDTO], project_id: str, data: dict[str, Any]) -> ProjectRecordDTO: """The record a write returns: its row as the write left it, read in the write's transaction, with the document the write stored. Not read back: the document is the largest part of the row, and decoding it again would take @@ -80,7 +142,7 @@ def _claim_board(q: Queries, *, user_id: str, board_id: str, board_name: str, pr board = q.boards.lock(board_id) if board is None: raise ProjectBoardNotFoundError(board_id) - owner, visibility = board + owner, general_access = board if owner != user_id: raise ProjectBoardNotFoundError(board_id) claimant = q.projects.claimant(board_id) @@ -88,7 +150,7 @@ def _claim_board(q: Queries, *, user_id: str, board_id: str, board_name: str, pr assert record is not None if ( record.project_id not in (None, project_id) - or visibility != BoardVisibility.Private.value + or general_access != GeneralAccess.Restricted.value or claimant not in (None, (user_id, project_id)) or q.boards.is_shared(board_id) ): @@ -123,19 +185,33 @@ def create( board_id: str | None = None, minimum_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, - ) -> ProjectRecordDTO: + minimum_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + workspace: Optional[dict[str, Any]] = None, + ) -> ProjectWithWorkspaceDTO: new_project_id = project_id or uuid.uuid4().hex _require_supported_schema( project_id=new_project_id, minimum_version=minimum_canvas_schema_version, client_maximum_version=max_canvas_schema_version, ) + _require_supported_document_schema( + project_id=new_project_id, + minimum_version=minimum_document_schema_version, + client_maximum_version=max_document_schema_version, + ) + # The workspace is the split's other half: beside a whole document nothing reads it, as in `update`. + if workspace is not None and minimum_document_schema_version < SPLIT_PROJECT_DOCUMENT_SCHEMA_VERSION: + raise ProjectWorkspaceBeforeSplitError(new_project_id, minimum_document_schema_version) document_json, document_bytes = _serialize_project_document(data) _require_project_document_size(document_bytes) references = extract_media_references(data) + workspace_json, workspace_bytes = ("", 0) if workspace is None else _serialize_project_document(workspace) + _require_project_document_size(workspace_bytes) + workspace_references = MediaReferences() if workspace is None else extract_media_references(workspace) board_name = name[:BOARD_NAME_MAX_LENGTH] - def insert(q: Queries) -> Optional[ProjectSummaryDTO]: + def insert(q: Queries) -> tuple[Optional[ProjectSummaryDTO], Optional[tuple[int, str]]]: # Shared with every write that makes media protected, exclusive for the intermediates cleanup's check and # delete: the media this names cannot be deleted between that check and this commit. q.locks.acquire(DatabaseLock.MEDIA_PROTECTION, shared=True) @@ -155,29 +231,53 @@ def insert(q: Queries) -> Optional[ProjectSummaryDTO]: data=document_json, board_id=project_board_id, minimum_canvas_schema_version=minimum_canvas_schema_version, + minimum_document_schema_version=minimum_document_schema_version, ) except UniqueViolation as error: - # The account has a project with this id, created meanwhile or, when the create brings no board, - # before. It cannot be the board: a new board is the project's alone, and a claimed one is locked and - # claimed by no other project. The board insert or rename rolls back with the project insert, so a - # refused create changes no board. + # A project has this id: the account's own, created meanwhile or, when the create brings no board, + # before; or another account's, since ids are global. It cannot be the board: a new board is the + # project's alone, and a claimed one is locked and claimed by no other project. The board insert or + # rename rolls back with the project insert, so a refused create changes no board. raise ProjectRecordExistsError(new_project_id) from error # Indexed in the document's own transaction: a reader never sees a saved project whose media the # intermediates cleanup does not know about. q.media_references.replace( owner_kind="project", user_id=user_id, owner_id=new_project_id, references=references ) - return q.projects.summary(user_id, new_project_id) + stamp = None + if workspace is not None: + # In the project's transaction: the creator's workspace is never a second request that a sign-out + # can lose, and a refused create leaves no workspace row. Always the first row: the project is new. + stamp = _write_workspace( + q, + user_id=user_id, + project_id=new_project_id, + expected_revision=None, + document_json=workspace_json, + references=workspace_references, + ) + return q.projects.summary(user_id, new_project_id), stamp - return _record(self._queries.run(insert), new_project_id, data) + summary, stamp = self._queries.run(insert) + record = _record(summary, new_project_id, data) + saved_workspace = None + if workspace is not None and stamp is not None: + saved_workspace = ProjectWorkspaceDTO(data=workspace, revision=stamp[0], updated_at=stamp[1]) + # Shallow, like `get`: `model_dump` would copy the whole content. + return ProjectWithWorkspaceDTO(**dict(record), workspace=saved_workspace) def get( self, user_id: str, project_id: str, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, - ) -> ProjectRecordDTO: - record = self._queries.projects.get(user_id, project_id) + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ) -> ProjectWithWorkspaceDTO: + def read(q: Queries) -> tuple[Optional[ProjectRecordDTO], Optional[ProjectWorkspaceDTO]]: + # One snapshot: both rows as they stood at one instant, though each half is saved on its own. + return q.projects.get(user_id, project_id), q.projects.workspace(user_id, project_id) + + record, workspace = self._queries.run(read, read_only=True) if record is None: raise ProjectRecordNotFoundError(project_id) _require_supported_schema( @@ -185,7 +285,13 @@ def get( minimum_version=record.minimum_canvas_schema_version, client_maximum_version=max_canvas_schema_version, ) - return record + _require_supported_document_schema( + project_id=project_id, + minimum_version=record.minimum_document_schema_version, + client_maximum_version=max_document_schema_version, + ) + # Shallow: `model_dump` would copy the whole decoded content, which can run to tens of megabytes. + return ProjectWithWorkspaceDTO(**dict(record), workspace=workspace) def list(self, user_id: str) -> list[ProjectSummaryDTO]: return self._queries.projects.summaries(user_id) @@ -199,11 +305,16 @@ def update( data: dict[str, Any], minimum_canvas_schema_version: int | None = None, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, - ) -> ProjectRecordDTO: + minimum_document_schema_version: int | None = None, + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + workspace: Optional[ProjectUpdateWorkspaceRequest] = None, + ) -> ProjectWithWorkspaceDTO: document_json, document_bytes = _serialize_project_document(data) references = extract_media_references(data) + workspace_json, workspace_bytes = ("", 0) if workspace is None else _serialize_project_document(workspace.data) + workspace_references = MediaReferences() if workspace is None else extract_media_references(workspace.data) - def save(q: Queries) -> Optional[ProjectSummaryDTO]: + def save(q: Queries) -> tuple[Optional[ProjectSummaryDTO], Optional[tuple[int, str]]]: q.locks.acquire(DatabaseLock.MEDIA_PROTECTION, shared=True) # Locked before anything is checked, so the checks and the write apply to the same row, whatever other # transactions save, delete or create under this id meanwhile. @@ -216,6 +327,11 @@ def save(q: Queries) -> Optional[ProjectSummaryDTO]: minimum_version=current_minimum_version, client_maximum_version=max_canvas_schema_version, ) + _require_supported_document_schema( + project_id=project_id, + minimum_version=project.minimum_document_schema_version, + client_maximum_version=max_document_schema_version, + ) if project.revision != expected_revision: raise ProjectRecordConflictError(project_id, expected_revision, project.revision) next_minimum_version = ( @@ -232,7 +348,27 @@ def save(q: Queries) -> Optional[ProjectSummaryDTO]: minimum_version=next_minimum_version, client_maximum_version=max_canvas_schema_version, ) + next_document_floor = _next_document_floor( + project_id, project.minimum_document_schema_version, minimum_document_schema_version + ) + _require_supported_document_schema( + project_id=project_id, + minimum_version=next_document_floor, + client_maximum_version=max_document_schema_version, + ) + if workspace is not None and next_document_floor < SPLIT_PROJECT_DOCUMENT_SCHEMA_VERSION: + raise ProjectWorkspaceBeforeSplitError(project_id, next_document_floor) + # The split must land whole: stored content without the caller's workspace row would join every later + # load with a default workspace, whatever client sent it. + if ( + workspace is None + and project.minimum_document_schema_version + < SPLIT_PROJECT_DOCUMENT_SCHEMA_VERSION + <= next_document_floor + ): + raise ProjectSplitNeedsWorkspaceError(project_id, project.minimum_document_schema_version) _require_project_document_size(document_bytes) + _require_project_document_size(workspace_bytes) q.projects.save( user_id=user_id, @@ -240,14 +376,77 @@ def save(q: Queries) -> Optional[ProjectSummaryDTO]: name=name, data=document_json, minimum_canvas_schema_version=next_minimum_version, + minimum_document_schema_version=next_document_floor, ) q.boards.rename(project.board_id, name[:BOARD_NAME_MAX_LENGTH]) q.media_references.replace( owner_kind="project", user_id=user_id, owner_id=project_id, references=references ) - return q.projects.summary(user_id, project_id) + stamp = None + if workspace is not None: + # In the content's transaction: a workspace conflict rolls the content back with it, so a split + # never lands by half. + stamp = _write_workspace( + q, + user_id=user_id, + project_id=project_id, + expected_revision=workspace.expected_revision, + document_json=workspace_json, + references=workspace_references, + ) + return q.projects.summary(user_id, project_id), stamp + + summary, stamp = self._queries.run(save) + record = _record(summary, project_id, data) + saved_workspace = None + if workspace is not None and stamp is not None: + saved_workspace = ProjectWorkspaceDTO(data=workspace.data, revision=stamp[0], updated_at=stamp[1]) + # Shallow, like `get`: `model_dump` would copy the whole content. + return ProjectWithWorkspaceDTO(**dict(record), workspace=saved_workspace) + + def save_workspace( + self, + user_id: str, + project_id: str, + expected_revision: Optional[int], + data: dict[str, Any], + max_document_schema_version: int, + max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + ) -> ProjectWorkspaceDTO: + document_json, document_bytes = _serialize_project_document(data) + _require_project_document_size(document_bytes) + references = extract_media_references(data) + + def save(q: Queries) -> tuple[int, str]: + q.locks.acquire(DatabaseLock.MEDIA_PROTECTION, shared=True) + project = q.projects.lock(user_id, project_id) + if project is None: + raise ProjectRecordNotFoundError(project_id) + _require_supported_schema( + project_id=project_id, + minimum_version=project.minimum_canvas_schema_version, + client_maximum_version=max_canvas_schema_version, + ) + _require_supported_document_schema( + project_id=project_id, + minimum_version=project.minimum_document_schema_version, + client_maximum_version=max_document_schema_version, + ) + # The workspace is the split's other half: beside a whole document nothing reads it, as in `update`. + if project.minimum_document_schema_version < SPLIT_PROJECT_DOCUMENT_SCHEMA_VERSION: + raise ProjectWorkspaceBeforeSplitError(project_id, project.minimum_document_schema_version) + return _write_workspace( + q, + user_id=user_id, + project_id=project_id, + expected_revision=expected_revision, + document_json=document_json, + references=references, + ) - return _record(self._queries.run(save), project_id, data) + stamp = self._queries.run(save) + # The document the client sent, not a decode of the stored one: the same JSON, and the larger part. + return ProjectWorkspaceDTO(data=data, revision=stamp[0], updated_at=stamp[1]) def delete(self, user_id: str, project_id: str, boards: Literal["release", "delete"] = "release") -> None: def delete(q: Queries) -> None: @@ -257,9 +456,12 @@ def delete(q: Queries) -> None: if project is None: return q.boards.lock(project.board_id) + # Read before the project's row goes, and its workspaces with it; its lock keeps new ones from being saved. + members = q.projects.workspace_members(project_id) q.boards.remove_project_members(user_id, project_id, project.board_id, delete_members=boards == "delete") q.projects.delete(user_id, project_id) q.media_references.delete(owner_kind="project", user_id=user_id, owner_id=project_id) + q.media_references.delete_many_owners(owner_kind="project_workspace", user_ids=members, owner_id=project_id) # Only after the project is gone: a claimed board cannot be deleted. Deleting the board deletes its # memberships, returning the media to Uncategorized; the image and video records and their files are # untouched. diff --git a/invokeai/app/services/session_processor/workflow_call_batch.py b/invokeai/app/services/session_processor/workflow_call_batch.py index d2416c058b3..5b041fb14c2 100644 --- a/invokeai/app/services/session_processor/workflow_call_batch.py +++ b/invokeai/app/services/session_processor/workflow_call_batch.py @@ -10,7 +10,7 @@ from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator from invokeai.app.invocations.fields import ImageField -from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy, BoardVisibility +from invokeai.app.services.board_access.board_access_common import BoardAccessDeniedError, BoardAction, queue_viewer from invokeai.app.services.image_records.image_records_common import ASSETS_CATEGORIES, IMAGE_CATEGORIES from invokeai.app.services.session_queue.session_queue_common import ( Batch, @@ -22,7 +22,6 @@ ) from invokeai.app.services.shared.execution_state_migration import load_execution_state from invokeai.app.services.shared.graph import GraphExecutionState, WorkflowCallFrame -from invokeai.app.services.shared.pagination import SQLiteDirection from invokeai.app.services.shared.workflow_graph_builder import ( UnsupportedWorkflowNodeError, apply_workflow_inputs_to_workflow, @@ -313,52 +312,22 @@ def _assert_user_can_access_board(board_id: str, services: Any, user_id: str | N if not user_id: return - board_records = getattr(services, "board_records", None) - if board_records is None or not hasattr(board_records, "get"): - return - - users = getattr(services, "users", None) - user = users.get(user_id) if users is not None and hasattr(users, "get") else None - is_admin = bool(user and getattr(user, "is_admin", False)) - if is_admin: - return - + denied = f"call_saved_workflow caller does not have access to board '{board_id}' for image generator expansion" try: - board_record = board_records.get(board_id) + # The expansion reads the board as the queue user would at execution time: a deleted or deactivated account + # is refused, an active one brings its own admin flag, and single-user mode runs as an admin. A lookup or + # access check that fails is the board being unreachable, not a crash of the expansion. + viewer = queue_viewer(user_id, multiuser=services.configuration.multiuser, users=services.users) + if viewer is not None: + services.board_access.require(board_id, viewer.user_id, viewer.is_admin, BoardAction.Read) + except BoardAccessDeniedError as e: + raise UnsupportedWorkflowNodeError(denied) from e except Exception as e: raise UnsupportedWorkflowNodeError( f"call_saved_workflow could not access board '{board_id}' for image generator expansion" ) from e - - if getattr(board_record, "user_id", None) == user_id: - return - - board_visibility = getattr(board_record, "board_visibility", BoardVisibility.Private) - if isinstance(board_visibility, str): - try: - board_visibility = BoardVisibility(board_visibility) - except ValueError: - board_visibility = BoardVisibility.Private - if board_visibility in {BoardVisibility.Shared, BoardVisibility.Public}: - return - - if hasattr(board_records, "get_all"): - try: - accessible_boards = board_records.get_all( - user_id=user_id, - is_admin=False, - order_by=BoardRecordOrderBy.Name, - direction=SQLiteDirection.Ascending, - include_archived=True, - ) - except Exception: - accessible_boards = [] - if any(getattr(board, "board_id", None) == board_id for board in accessible_boards): - return - - raise UnsupportedWorkflowNodeError( - f"call_saved_workflow caller does not have access to board '{board_id}' for image generator expansion" - ) + if viewer is None: + raise UnsupportedWorkflowNodeError(denied) def _resolve_image_generator(value: Mapping[str, Any], services: Any, user_id: str | None) -> list[ImageField]: diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index e95d0cc9125..3e7bc4841fb 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -76,18 +76,26 @@ def get_current(self, queue_id: str, origin_prefix: Optional[str] = None) -> Opt """Gets the currently-executing session queue item""" pass - def get_current_for_api(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: - """Gets the current item in the response projection used by API reads.""" - return self.get_current(queue_id=queue_id, origin_prefix=origin_prefix) + @abstractmethod + def get_current_for_api( + self, queue_id: str, origin_prefix: Optional[str] = None, owner_id: Optional[str] = None + ) -> Optional[SessionQueueItem]: + """Gets the current item in the response projection used by API reads; with owner_id, of that user's items + only.""" + pass @abstractmethod def get_next(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: """Gets the next session queue item (does not dequeue it)""" pass - def get_next_for_api(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: - """Gets the next item in the response projection used by API reads.""" - return self.get_next(queue_id=queue_id, origin_prefix=origin_prefix) + @abstractmethod + def get_next_for_api( + self, queue_id: str, origin_prefix: Optional[str] = None, owner_id: Optional[str] = None + ) -> Optional[SessionQueueItem]: + """Gets the next item in the response projection used by API reads; with owner_id, of that user's items + only.""" + pass @abstractmethod def clear(self, queue_id: str, user_id: Optional[str] = None) -> ClearResult: @@ -117,10 +125,12 @@ def get_queue_status( acting_user_id: Optional[str] = None, origin_prefix: Optional[str] = None, is_admin: bool = False, + owner_id: Optional[str] = None, ) -> SessionQueueStatus: """Gets the status of the queue. - Aggregate counts (pending/in_progress/.../total) are always global across all users. + owner_id limits everything below, counts and current item alike, to that user's items; it is a filter, + unlike user_id. Without it, aggregate counts (pending/in_progress/.../total) are global across all users. If user_id is provided, the requesting user's own counts are additionally returned in the user_pending/user_in_progress fields (left None otherwise). Admin callers should also pass their user_id so personal UI (e.g. the progress bar) can distinguish their @@ -269,13 +279,13 @@ def get_queue_item_ids( self, queue_id: str, order_dir: SQLiteDirection = SQLiteDirection.Descending, - user_id: Optional[str] = None, + owner_id: Optional[str] = None, origin_prefix: Optional[str] = None, limit: Optional[int] = None, ) -> ItemIdsResult: """Gets the ids of queue items that match the given parameters, in the requested created_at order. - If user_id is provided, only returns items for that user. If limit is provided, returns only the first + If owner_id is provided, only returns items for that user. If limit is provided, returns only the first `limit` ids of that order. total_count is the number of ids in item_ids, which equals the number of matching items only when no limit is given.""" pass diff --git a/invokeai/app/services/session_queue/session_queue_default.py b/invokeai/app/services/session_queue/session_queue_default.py index 87e30f11d64..01df20c0052 100644 --- a/invokeai/app/services/session_queue/session_queue_default.py +++ b/invokeai/app/services/session_queue/session_queue_default.py @@ -448,16 +448,24 @@ def get_current(self, queue_id: str, origin_prefix: Optional[str] = None) -> Opt return self._hydrate_queue_item(raw_queue_item, quarantine=False)[0] if raw_queue_item is not None else None def _get_queue_item_by_status_for_api( - self, queue_id: str, status: Literal["pending", "in_progress"], origin_prefix: Optional[str] + self, + queue_id: str, + status: Literal["pending", "in_progress"], + origin_prefix: Optional[str], + owner_id: Optional[str], ) -> Optional[SessionQueueItem]: - raw_queue_item = self._queries.session_queue.item_by_status(queue_id, status, origin_prefix) + raw_queue_item = self._queries.session_queue.item_by_status(queue_id, status, origin_prefix, owner_id) return self._project_queue_item_for_read(raw_queue_item) if raw_queue_item is not None else None - def get_current_for_api(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: - return self._get_queue_item_by_status_for_api(queue_id, "in_progress", origin_prefix) + def get_current_for_api( + self, queue_id: str, origin_prefix: Optional[str] = None, owner_id: Optional[str] = None + ) -> Optional[SessionQueueItem]: + return self._get_queue_item_by_status_for_api(queue_id, "in_progress", origin_prefix, owner_id) - def get_next_for_api(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: - return self._get_queue_item_by_status_for_api(queue_id, "pending", origin_prefix) + def get_next_for_api( + self, queue_id: str, origin_prefix: Optional[str] = None, owner_id: Optional[str] = None + ) -> Optional[SessionQueueItem]: + return self._get_queue_item_by_status_for_api(queue_id, "pending", origin_prefix, owner_id) def _set_queue_item_status( self, @@ -1013,14 +1021,14 @@ def get_queue_item_ids( self, queue_id: str, order_dir: SQLiteDirection = SQLiteDirection.Descending, - user_id: Optional[str] = None, + owner_id: Optional[str] = None, origin_prefix: Optional[str] = None, limit: Optional[int] = None, ) -> ItemIdsResult: item_ids = self._queries.session_queue.item_ids( queue_id, descending=order_dir == SQLiteDirection.Descending, - user_id=user_id, + owner_id=owner_id, origin_prefix=origin_prefix, limit=limit, ) @@ -1045,13 +1053,16 @@ def get_queue_status( acting_user_id: Optional[str] = None, origin_prefix: Optional[str] = None, is_admin: bool = False, + owner_id: Optional[str] = None, ) -> SessionQueueStatus: - # Aggregate counts are global across all users within the requested scope. With user_id, the - # same snapshot also yields that user's own counts, for the per-user portion of the badge; - # they are returned in separate fields and never replace the global counts. Only the current + # Aggregate counts cover every user's items within the requested scope (only owner_id's, if + # given). With user_id, the same snapshot also yields that user's own counts, for the per-user + # portion of the badge; they are returned in separate fields and never replace the aggregate. Only the current # item's identifiers are read, not a full SessionQueueItem: this runs on every status poll and # every status change, and hydrating the item would deserialize its whole session graph. - counts, own_counts, current_item = self._queries.session_queue.status_counts(queue_id, user_id, origin_prefix) + counts, own_counts, current_item = self._queries.session_queue.status_counts( + queue_id, user_id, origin_prefix, owner_id + ) user_pending: Optional[int] = None user_in_progress: Optional[int] = None @@ -1061,17 +1072,19 @@ def get_queue_status( # Redaction is decided from the same current_item snapshot used to embed identifiers, # so a concurrent transition (e.g. B finishing while A's status changes) cannot leave - # stale identifiers in the result. The aggregate counts stay global; only the current + # stale identifiers in the result. The aggregate counts are not redacted; only the current # item's identifiers are gated. acting_user_id (event path) takes precedence over # user_id (API path) when deciding the redaction owner; either being None means a # global caller who may see the current item. is_admin disables redaction outright so # admin callers can pass their user_id (for the per-user counts) without losing # visibility of other users' current item. - owner_user_id = user_id if acting_user_id is None else acting_user_id + redaction_subject = user_id if acting_user_id is None else acting_user_id current_item_id = None current_session_id = None current_batch_id = None - if current_item is not None and (is_admin or owner_user_id is None or current_item.user_id == owner_user_id): + if current_item is not None and ( + is_admin or redaction_subject is None or current_item.user_id == redaction_subject + ): current_item_id = current_item.item_id current_session_id = current_item.session_id current_batch_id = current_item.batch_id diff --git a/invokeai/app/services/shared/database/queries/board_access.py b/invokeai/app/services/shared/database/queries/board_access.py index 42271800b69..5e2e15b9788 100644 --- a/invokeai/app/services/shared/database/queries/board_access.py +++ b/invokeai/app/services/shared/database/queries/board_access.py @@ -1,17 +1,12 @@ """Which boards an account may read: one rule for every query that lists boards or media through them.""" -from sqlalchemy import ColumnElement, exists, literal, or_ +from sqlalchemy import ColumnElement, exists, or_ -from invokeai.app.services.board_records.board_records_common import BoardVisibility -from invokeai.app.services.shared.database.schema.boards import boards, shared_boards - -_SHARED_VISIBILITIES = (literal(BoardVisibility.Shared.value), literal(BoardVisibility.Public.value)) +from invokeai.app.services.shared.database.schema.boards import board_members, boards def readable_board(user_id: ColumnElement[str]) -> ColumnElement[bool]: - """Boards an account that is not an administrator may read: its own, those shared or public, and those shared - with it. Archived boards are not excluded here: listings decide that themselves.""" - shared_with_account = exists().where( - shared_boards.c.board_id == boards.c.board_id, shared_boards.c.user_id == user_id - ) - return or_(boards.c.user_id == user_id, boards.c.board_visibility.in_(_SHARED_VISIBILITIES), shared_with_account) + """Boards an account that is not an administrator may read: its own, those every account gets a role on, and + those it is a member of. Archived boards are not excluded here: listings decide that themselves.""" + member = exists().where(board_members.c.board_id == boards.c.board_id, board_members.c.user_id == user_id) + return or_(boards.c.user_id == user_id, boards.c.general_access != "restricted", member) diff --git a/invokeai/app/services/shared/database/queries/boards.py b/invokeai/app/services/shared/database/queries/boards.py index 588fd9b00ea..16513986801 100644 --- a/invokeai/app/services/shared/database/queries/boards.py +++ b/invokeai/app/services/shared/database/queries/boards.py @@ -23,16 +23,16 @@ update, ) +from invokeai.app.services.board_access.board_access_common import GeneralAccess from invokeai.app.services.board_records.board_records_common import ( BoardChanges, BoardRecord, BoardRecordOrderBy, - BoardVisibility, ) from invokeai.app.services.shared.database.dialect import CaseInsensitiveOrder from invokeai.app.services.shared.database.queries.base import IN_CHUNK, QueryModule, locking, mapped, read, write from invokeai.app.services.shared.database.queries.board_access import readable_board -from invokeai.app.services.shared.database.schema.boards import boards, shared_boards +from invokeai.app.services.shared.database.schema.boards import board_members, boards from invokeai.app.services.shared.database.schema.projects import projects from invokeai.app.services.shared.pagination import SQLiteDirection @@ -45,7 +45,7 @@ boards.c.deleted_at, boards.c.cover_image_name, boards.c.archived, - boards.c.board_visibility, + boards.c.general_access, boards.c.project_id, ) _CLAIMED = exists().where(projects.c.board_id == boards.c.board_id) @@ -58,7 +58,7 @@ ) _EXISTS = select(literal(1)).where(boards.c.board_id == bindparam("board_id")) _LOCK = ( - select(boards.c.user_id, boards.c.board_visibility) + select(boards.c.user_id, boards.c.general_access) .where(boards.c.board_id == bindparam("board_id")) .with_for_update() ) @@ -67,11 +67,22 @@ ) _INSERT = insert(boards) _DELETE_IF_UNCLAIMED = delete(boards).where(boards.c.board_id == bindparam("board_id"), ~_CLAIMED) -_IS_SHARED_WITH = select(literal(1)).where( - shared_boards.c.board_id == bindparam("board_id"), shared_boards.c.user_id == bindparam("user_id") +_SHARED_USER_IDS = select(board_members.c.user_id).where(board_members.c.board_id == bindparam("board_id")) +# What a caller's access to a board rests on, in one query: its owner, general access, the caller's membership +# role, and whether it is a project's inbox. +_ACCESS_FACTS = select( + boards.c.user_id, + boards.c.general_access, + select(board_members.c.role) + .where(board_members.c.board_id == boards.c.board_id, board_members.c.user_id == bindparam("user_id")) + .scalar_subquery(), + _CLAIMED, +).where(boards.c.board_id == bindparam("board_id")) +_MEMBER_ROLES = select(board_members.c.board_id, board_members.c.role).where( + board_members.c.user_id == bindparam("user_id"), + board_members.c.board_id.in_(bindparam("board_ids", expanding=True)), ) -_SHARED_USER_IDS = select(shared_boards.c.user_id).where(shared_boards.c.board_id == bindparam("board_id")) -_IS_SHARED = select(exists().where(shared_boards.c.board_id == bindparam("board_id"))) +_IS_SHARED = select(exists().where(board_members.c.board_id == bindparam("board_id"))) _RENAME = ( update(boards) .where(boards.c.board_id == bindparam("target_board_id")) @@ -89,6 +100,11 @@ boards.c.board_id != bindparam("inbox_id"), ) _RELEASE_MEMBERS = update(boards).where(*_OTHER_MEMBERS).values(project_id=None) +_RELEASE_OWNED = ( + update(boards) + .where(boards.c.user_id == bindparam("target_user_id"), boards.c.project_id.is_not(None)) + .values(project_id=None) +) _DELETE_MEMBERS = delete(boards).where(*_OTHER_MEMBERS) @@ -102,13 +118,9 @@ def _board(row: Sequence[Any]) -> BoardRecord: deleted_at, cover_image_name, archived, - visibility, + general_access, project_id, ) = row - try: - board_visibility = BoardVisibility(visibility) - except ValueError: - board_visibility = BoardVisibility.Private return BoardRecord( board_id=board_id, board_name=board_name, @@ -118,7 +130,7 @@ def _board(row: Sequence[Any]) -> BoardRecord: deleted_at=deleted_at, cover_image_name=cover_image_name, archived=archived, - board_visibility=board_visibility, + general_access=GeneralAccess(general_access), project_id=project_id, ) @@ -143,7 +155,7 @@ def _boards_and_total(result: tuple[Sequence[Sequence[Any]], int]) -> tuple[list @functools.cache def _update(changes_owned_state: bool) -> Update: """Sets the given fields (a NULL leaves one as it is). A change to what a project owns -- the name, the - archived flag, the visibility -- applies only to a board no project claims.""" + archived flag, the general access -- applies only to a board no project claims.""" statement = ( update(boards) .where(boards.c.board_id == bindparam("target_board_id")) @@ -157,8 +169,8 @@ def _update(changes_owned_state: bool) -> Update: (bindparam("moves_board"), bindparam("new_project_id", type_=boards.c.project_id.type)), else_=boards.c.project_id, ), - board_visibility=func.coalesce( - bindparam("new_board_visibility", type_=boards.c.board_visibility.type), boards.c.board_visibility + general_access=func.coalesce( + bindparam("new_general_access", type_=boards.c.general_access.type), boards.c.general_access ), ) ) @@ -219,19 +231,19 @@ def exists(self, conn: Connection, board_id: str) -> bool: @locking def lock(self, conn: Connection, board_id: str) -> Optional[tuple[str, str]]: - """Locks the board's row until the transaction ends: its owner and visibility, or None when there is no + """Locks the board's row until the transaction ends: its owner and general access, or None when there is no such board. Locked before a transaction reads what it decides on, the row makes every transaction that changes the - board -- its owner, visibility or sharing, a project claiming it, its deletion -- wait until this one has + board -- its owner, access or sharing, a project claiming it, its deletion -- wait until this one has ended, and this one's later reads see what such a transaction committed before. Lock the board's project first, if there is one: rows are locked in that order. """ row: Optional[Sequence[Any]] = conn.execute(_LOCK, {"board_id": board_id}).first() if row is None: return None - user_id, board_visibility = row - return user_id, board_visibility + user_id, general_access = row + return user_id, general_access @read def project_ids(self, conn: Connection, board_ids: Sequence[str]) -> dict[str, str]: @@ -277,14 +289,33 @@ def all( statement = _list(is_admin, include_archived, order_by, direction, False) return conn.execute(statement, {"user_id": user_id}).all() - @read - def is_shared_with(self, conn: Connection, board_id: str, user_id: str) -> bool: - return conn.execute(_IS_SHARED_WITH, {"board_id": board_id, "user_id": user_id}).first() is not None - @read def shared_user_ids(self, conn: Connection, board_id: str) -> list[str]: return list(conn.execute(_SHARED_USER_IDS, {"board_id": board_id}).scalars().all()) + @read + def access_facts( + self, conn: Connection, board_id: str, user_id: str + ) -> Optional[tuple[str, str, Optional[str], bool]]: + """The board's owner and general access, the user's membership role on it, and whether it is a project's + inbox; None when there is no such board.""" + row: Optional[Sequence[Any]] = conn.execute(_ACCESS_FACTS, {"board_id": board_id, "user_id": user_id}).first() + if row is None: + return None + owner_id, general_access, member_role, is_inbox = row + return owner_id, general_access, member_role, bool(is_inbox) + + @read + def member_roles(self, conn: Connection, board_ids: Sequence[str], user_id: str) -> dict[str, str]: + """The user's membership role on each of these boards it is a member of.""" + roles: dict[str, str] = {} + for chunk in itertools.batched(board_ids, IN_CHUNK): + roles.update( + (row[0], row[1]) + for row in conn.execute(_MEMBER_ROLES, {"board_ids": list(chunk), "user_id": user_id}).all() + ) + return roles + @read def is_shared(self, conn: Connection, board_id: str) -> bool: """Whether the board is shared with any account.""" @@ -301,11 +332,11 @@ def insert( @write def update(self, conn: Connection, board_id: str, changes: BoardChanges) -> bool: """Applies the changes; whether a board took them. A board a project claims keeps its name, archived flag - and visibility, so a change to one of those leaves it as it is.""" + and general access, so a change to one of those leaves it as it is.""" changes_owned_state = ( changes.board_name is not None or changes.archived is not None - or changes.board_visibility is not None + or changes.general_access is not None or changes.moves_board ) result = conn.execute( @@ -317,9 +348,7 @@ def update(self, conn: Connection, board_id: str, changes: BoardChanges) -> bool "new_board_name": changes.board_name, "new_cover_image_name": changes.cover_image_name, "new_archived": changes.archived, - "new_board_visibility": ( - changes.board_visibility.value if changes.board_visibility is not None else None - ), + "new_general_access": changes.general_access.value if changes.general_access is not None else None, }, ) return result.rowcount > 0 @@ -347,6 +376,11 @@ def remove_project_members( {"target_user_id": user_id, "target_project_id": project_id, "inbox_id": inbox_id}, ) + @write + def release_owned(self, conn: Connection, user_id: str) -> None: + """Moves every board the account owns to its Library: for the account's deletion, which takes its projects.""" + conn.execute(_RELEASE_OWNED, {"target_user_id": user_id}) + @write def delete_if_unclaimed(self, conn: Connection, board_id: str) -> bool: """Deletes the board unless a project claims it; whether it did.""" diff --git a/invokeai/app/services/shared/database/queries/intermediates.py b/invokeai/app/services/shared/database/queries/intermediates.py index 81ee425e541..d0e67e720d0 100644 --- a/invokeai/app/services/shared/database/queries/intermediates.py +++ b/invokeai/app/services/shared/database/queries/intermediates.py @@ -135,6 +135,10 @@ def _protected_items() -> Subquery: _DOCUMENT_NAMES = { "project": select(_P.user_id, _P.project_id, _P.name).where(_P.project_id.in_(bindparam("ids", expanding=True))), + # A workspace is named after its project, whose owner need not be the workspace's. + "project_workspace": select(null(), _P.project_id, _P.name).where( + _P.project_id.in_(bindparam("ids", expanding=True)) + ), "quarantined_project": select( orphaned_projects_2026_08_06.c.user_id, orphaned_projects_2026_08_06.c.project_id, diff --git a/invokeai/app/services/shared/database/queries/media_references.py b/invokeai/app/services/shared/database/queries/media_references.py index d7cecf90307..4b8edf344e5 100644 --- a/invokeai/app/services/shared/database/queries/media_references.py +++ b/invokeai/app/services/shared/database/queries/media_references.py @@ -55,6 +55,15 @@ def replace( def delete(self, conn: Connection, *, owner_kind: MediaReferenceOwnerKind, user_id: str, owner_id: str) -> None: conn.execute(_DELETE_OWNER, {"owner_kind": owner_kind, "user_id": user_id, "owner_id": owner_id}) + @write + def delete_many_owners( + self, conn: Connection, *, owner_kind: MediaReferenceOwnerKind, user_ids: Collection[str], owner_id: str + ) -> None: + """Deletes the references several accounts hold through one document, such as each member's workspace in a + project: by key, not by a scan of every such document's references.""" + for user_id in user_ids: + conn.execute(_DELETE_OWNER, {"owner_kind": owner_kind, "user_id": user_id, "owner_id": owner_id}) + @write def delete_many( self, conn: Connection, *, owner_kind: MediaReferenceOwnerKind, user_id: str, owner_ids: Collection[str] diff --git a/invokeai/app/services/shared/database/queries/projects.py b/invokeai/app/services/shared/database/queries/projects.py index a7b8015e8db..78b309ab5bc 100644 --- a/invokeai/app/services/shared/database/queries/projects.py +++ b/invokeai/app/services/shared/database/queries/projects.py @@ -1,5 +1,6 @@ """Canvas projects: their documents, revisions, and the board each one claims.""" +import itertools import json from collections.abc import Sequence from typing import Any, NamedTuple, Optional @@ -8,6 +9,7 @@ ColumnElement, Connection, Row, + and_, bindparam, case, delete, @@ -27,12 +29,13 @@ ProjectBoardSnapshotDTO, ProjectRecordDTO, ProjectSummaryDTO, + ProjectWorkspaceDTO, ) from invokeai.app.services.shared.database.dialect import OrderedJoin -from invokeai.app.services.shared.database.queries.base import QueryModule, locking, mapped, read, write +from invokeai.app.services.shared.database.queries.base import IN_CHUNK, QueryModule, locking, mapped, read, write from invokeai.app.services.shared.database.schema.boards import board_images, board_videos, boards from invokeai.app.services.shared.database.schema.images import images -from invokeai.app.services.shared.database.schema.projects import projects +from invokeai.app.services.shared.database.schema.projects import project_workspaces, projects from invokeai.app.services.shared.database.schema.videos import videos @@ -41,35 +44,67 @@ class LockedProject(NamedTuple): revision: int minimum_canvas_schema_version: int + minimum_document_schema_version: int board_id: str +_W = project_workspaces.c _SUMMARY_COLUMNS = ( projects.c.project_id, projects.c.board_id, projects.c.name, projects.c.revision, projects.c.minimum_canvas_schema_version, + projects.c.minimum_document_schema_version, projects.c.created_at, projects.c.updated_at, + _W.updated_at, +) +# The caller's workspace beside each project: `user_id` is the caller, who in 7.0 is always the owner. +_WITH_CALLERS_WORKSPACE = projects.outerjoin( + project_workspaces, and_(_W.project_id == projects.c.project_id, _W.user_id == bindparam("user_id")) ) _THE_PROJECT = (projects.c.user_id == bindparam("user_id"), projects.c.project_id == bindparam("project_id")) -_GET = select(*_SUMMARY_COLUMNS, projects.c.data).where(*_THE_PROJECT) -_SUMMARY = select(*_SUMMARY_COLUMNS).where(*_THE_PROJECT) +_GET = select(*_SUMMARY_COLUMNS, projects.c.data).select_from(_WITH_CALLERS_WORKSPACE).where(*_THE_PROJECT) +_SUMMARY = select(*_SUMMARY_COLUMNS).select_from(_WITH_CALLERS_WORKSPACE).where(*_THE_PROJECT) # Oldest first. Projects created in the same millisecond come in the order of their ids. _SUMMARIES = ( select(*_SUMMARY_COLUMNS) + .select_from(_WITH_CALLERS_WORKSPACE) .where(projects.c.user_id == bindparam("user_id")) .order_by(projects.c.created_at, projects.c.project_id) ) _BOARD_ID = select(projects.c.board_id).where(*_THE_PROJECT) +_OWNED_IDS = select(projects.c.project_id).where( + projects.c.user_id == bindparam("user_id"), projects.c.project_id.in_(bindparam("project_ids", expanding=True)) +) _LOCK = ( - select(projects.c.revision, projects.c.minimum_canvas_schema_version, projects.c.board_id) + select( + projects.c.revision, + projects.c.minimum_canvas_schema_version, + projects.c.minimum_document_schema_version, + projects.c.board_id, + ) .where(*_THE_PROJECT) .with_for_update() ) +_THE_WORKSPACE = (_W.project_id == bindparam("project_id"), _W.user_id == bindparam("user_id")) +_WORKSPACE_MEMBERS = select(_W.user_id).where(_W.project_id == bindparam("project_id")) +_WORKSPACES_IN_OWNED = ( + select(_W.user_id, _W.project_id) + .select_from(projects.join(project_workspaces, _W.project_id == projects.c.project_id)) + .where(projects.c.user_id == bindparam("user_id")) +) +_WORKSPACE = select(_W.data, _W.revision, _W.updated_at).where(*_THE_WORKSPACE) +_WORKSPACE_STAMP = select(_W.revision, _W.updated_at).where(*_THE_WORKSPACE) +_INSERT_WORKSPACE = insert(project_workspaces) +_SAVE_WORKSPACE = ( + update(project_workspaces) + .where(_W.project_id == bindparam("target_project_id"), _W.user_id == bindparam("target_user_id")) + .values(data=bindparam("new_data"), revision=_W.revision + 1) +) _CLAIMANT = select(projects.c.user_id, projects.c.project_id).where(projects.c.board_id == bindparam("board_id")) _INSERT = insert(projects) _SAVE = ( @@ -79,6 +114,7 @@ class LockedProject(NamedTuple): name=bindparam("new_name"), data=bindparam("new_data"), minimum_canvas_schema_version=bindparam("new_minimum_canvas_schema_version"), + minimum_document_schema_version=bindparam("new_minimum_document_schema_version"), revision=projects.c.revision + 1, ) ) @@ -139,17 +175,27 @@ def _shown_by_the_gallery(category: ColumnElement[str]) -> ColumnElement[bool]: _BOARD_ITEMS = _ITEMS.limit(bindparam("limit")) +# The fields `_SUMMARY_COLUMNS` select, in their order. +_SUMMARY_FIELDS = ( + "project_id", + "board_id", + "name", + "revision", + "minimum_canvas_schema_version", + "minimum_document_schema_version", + "created_at", + "updated_at", + "workspace_updated_at", +) +assert len(_SUMMARY_FIELDS) == len(_SUMMARY_COLUMNS) + + +def _summary_fields(row: Sequence[Any]) -> dict[str, Any]: + return dict(zip(_SUMMARY_FIELDS, row[: len(_SUMMARY_FIELDS)], strict=True)) + + def _summary(row: Sequence[Any]) -> ProjectSummaryDTO: - project_id, board_id, name, revision, minimum_canvas_schema_version, created_at, updated_at = row - return ProjectSummaryDTO( - project_id=project_id, - board_id=board_id, - name=name, - revision=revision, - minimum_canvas_schema_version=minimum_canvas_schema_version, - created_at=created_at, - updated_at=updated_at, - ) + return ProjectSummaryDTO(**_summary_fields(row)) def _summary_or_none(row: Optional[Sequence[Any]]) -> Optional[ProjectSummaryDTO]: @@ -163,17 +209,14 @@ def _summaries(rows: Sequence[Sequence[Any]]) -> list[ProjectSummaryDTO]: def _record_or_none(row: Optional[Sequence[Any]]) -> Optional[ProjectRecordDTO]: if row is None: return None - project_id, board_id, name, revision, minimum_canvas_schema_version, created_at, updated_at, data = row - return ProjectRecordDTO( - project_id=project_id, - board_id=board_id, - name=name, - revision=revision, - minimum_canvas_schema_version=minimum_canvas_schema_version, - created_at=created_at, - updated_at=updated_at, - data=json.loads(data), - ) + return ProjectRecordDTO(**_summary_fields(row), data=json.loads(row[-1])) + + +def _workspace_or_none(row: Optional[Sequence[Any]]) -> Optional[ProjectWorkspaceDTO]: + if row is None: + return None + data, revision, updated_at = row + return ProjectWorkspaceDTO(data=json.loads(data), revision=revision, updated_at=updated_at) def _board_snapshot_or_none( @@ -216,6 +259,14 @@ def summaries(self, conn: Connection, user_id: str) -> Sequence[Row[Any]]: def board_id(self, conn: Connection, user_id: str, project_id: str) -> Optional[str]: return conn.execute(_BOARD_ID, {"user_id": user_id, "project_id": project_id}).scalar_one_or_none() + @read + def owned_ids(self, conn: Connection, user_id: str, project_ids: Sequence[str]) -> set[str]: + """Those of these project ids that name one of the account's projects.""" + owned: set[str] = set() + for chunk in itertools.batched(project_ids, IN_CHUNK): + owned.update(conn.execute(_OWNED_IDS, {"user_id": user_id, "project_ids": list(chunk)}).scalars().all()) + return owned + @locking def lock(self, conn: Connection, user_id: str, project_id: str) -> Optional[LockedProject]: """Locks the project's row until the transaction ends: what a write of the project decides on, or None when @@ -223,8 +274,7 @@ def lock(self, conn: Connection, user_id: str, project_id: str) -> Optional[Lock row: Optional[Sequence[Any]] = conn.execute(_LOCK, {"user_id": user_id, "project_id": project_id}).first() if row is None: return None - revision, minimum_canvas_schema_version, board_id = row - return LockedProject(revision, minimum_canvas_schema_version, board_id) + return LockedProject(*row) @read def claimant(self, conn: Connection, board_id: str) -> Optional[tuple[str, str]]: @@ -261,6 +311,7 @@ def insert( data: str, board_id: str, minimum_canvas_schema_version: int, + minimum_document_schema_version: int, ) -> None: conn.execute( _INSERT, @@ -271,6 +322,7 @@ def insert( "data": data, "board_id": board_id, "minimum_canvas_schema_version": minimum_canvas_schema_version, + "minimum_document_schema_version": minimum_document_schema_version, }, ) @@ -284,6 +336,7 @@ def save( name: str, data: str, minimum_canvas_schema_version: int, + minimum_document_schema_version: int, ) -> None: """Writes the project as its next revision. The caller has locked it and checked the revision it saves over.""" conn.execute( @@ -294,9 +347,44 @@ def save( "new_name": name, "new_data": data, "new_minimum_canvas_schema_version": minimum_canvas_schema_version, + "new_minimum_document_schema_version": minimum_document_schema_version, }, ) + @mapped(_workspace_or_none) + @read + def workspace(self, conn: Connection, user_id: str, project_id: str) -> Optional[Row[Any]]: + """The account's workspace in the project, if it has saved one.""" + return conn.execute(_WORKSPACE, {"user_id": user_id, "project_id": project_id}).first() + + @read + def workspace_members(self, conn: Connection, project_id: str) -> list[str]: + """The accounts with a workspace in the project.""" + return list(conn.execute(_WORKSPACE_MEMBERS, {"project_id": project_id}).scalars().all()) + + @read + def workspaces_in_owned(self, conn: Connection, user_id: str) -> list[tuple[str, str]]: + """(member, project) of every workspace in the account's projects, the account's own among them.""" + rows: Sequence[Sequence[Any]] = conn.execute(_WORKSPACES_IN_OWNED, {"user_id": user_id}).all() + return [(row[0], row[1]) for row in rows] + + @read + def workspace_stamp(self, conn: Connection, user_id: str, project_id: str) -> Optional[tuple[int, str]]: + """The workspace's revision and when it was saved, without its document.""" + row: Optional[Sequence[Any]] = conn.execute( + _WORKSPACE_STAMP, {"user_id": user_id, "project_id": project_id} + ).first() + return (row[0], row[1]) if row is not None else None + + @write + def insert_workspace(self, conn: Connection, *, user_id: str, project_id: str, data: str) -> None: + conn.execute(_INSERT_WORKSPACE, {"project_id": project_id, "user_id": user_id, "data": data}) + + @write + def save_workspace(self, conn: Connection, *, user_id: str, project_id: str, data: str) -> None: + """Writes the workspace as its next revision. The caller has locked the project and checked the revision.""" + conn.execute(_SAVE_WORKSPACE, {"target_project_id": project_id, "target_user_id": user_id, "new_data": data}) + @write def delete(self, conn: Connection, user_id: str, project_id: str) -> None: conn.execute(_DELETE, {"user_id": user_id, "project_id": project_id}) diff --git a/invokeai/app/services/shared/database/queries/session_queue.py b/invokeai/app/services/shared/database/queries/session_queue.py index e0d29451408..120ee0c38af 100644 --- a/invokeai/app/services/shared/database/queries/session_queue.py +++ b/invokeai/app/services/shared/database/queries/session_queue.py @@ -115,11 +115,13 @@ def _origin_pattern(origin_prefix: Optional[str]) -> Optional[str]: @functools.cache -def _item_by_status(status: str, by_origin: bool) -> Select[Any]: +def _item_by_status(status: str, by_origin: bool, by_owner: bool) -> Select[Any]: """The pending item to run next, or an item in progress (the earliest enqueued of several).""" conditions = [_IN_QUEUE_UNINDEXED, _Q.status == literal(status)] if by_origin: conditions.append(_origin_matches()) + if by_owner: + conditions.append(Unindexed(_Q.user_id) == bindparam("owner_id")) ordering = [_Q.priority.desc(), _Q.created_at, _Q.item_id] if status == "pending" else [_Q.item_id] return select(_Q.item_id).where(*conditions).order_by(*ordering).limit(fixed_limit(1)) @@ -160,12 +162,13 @@ def _page(by_status: bool, by_destination: bool, after: bool) -> Select[Any]: @functools.cache -def _item_ids(descending: bool, by_user: bool, by_origin: bool, limited: bool) -> Select[Any]: +def _item_ids(descending: bool, by_owner: bool, by_origin: bool, limited: bool) -> Select[Any]: """The ids, in an order the listing index yields on SQLite without a sort (newest first), or sorting only the items that share a creation time (oldest first); so a limited read stops after its window.""" conditions = [_IN_QUEUE] - if by_user: - conditions.append(_Q.user_id == bindparam("user_id")) + if by_owner: + # Off the account's own index, which holds no creation time: the listing index reads it in order. + conditions.append(Unindexed(_Q.user_id) == bindparam("owner_id")) if by_origin: conditions.append(_origin_matches()) # The item id breaks ties, in enqueue order either way, as SQLite returned them before: a batch's items share @@ -176,22 +179,26 @@ def _item_ids(descending: bool, by_user: bool, by_origin: bool, limited: bool) - @functools.cache -def _status_counts(with_own: bool, by_origin: bool) -> Select[Any]: - """Every status's count in the queue and, `with_own`, in the account's part of it, in one pass: on SQLite the - listing index holds every column read, so no row is.""" +def _status_counts(with_own: bool, by_origin: bool, by_owner: bool) -> Select[Any]: + """Every status's count in the queue (or one account's part of it, `by_owner`) and, `with_own`, in the caller's + part of it, in one pass: on SQLite the listing index holds every column read, so no row is.""" conditions = [_IN_QUEUE] if by_origin: conditions.append(_origin_matches()) + if by_owner: + conditions.append(Unindexed(_Q.user_id) == bindparam("owner_id")) own = func.sum(case((_Q.user_id == bindparam("user_id"), 1), else_=0)) if with_own else literal(0) return select(_Q.status, func.count(), own).where(*conditions).group_by(_Q.status) @functools.cache -def _current(by_origin: bool) -> Select[Any]: +def _current(by_origin: bool, by_owner: bool) -> Select[Any]: """The identifiers of the item in progress, the earliest enqueued of several.""" conditions = [_IN_QUEUE_UNINDEXED, _IN_PROGRESS] if by_origin: conditions.append(_origin_matches()) + if by_owner: + conditions.append(Unindexed(_Q.user_id) == bindparam("owner_id")) return ( select(_Q.item_id, _Q.session_id, _Q.batch_id, _Q.user_id) .where(*conditions) @@ -757,12 +764,17 @@ def item(self, conn: Connection, item_id: int) -> Optional[Row[Any]]: @mapped(_item_or_none) @read def item_by_status( - self, conn: Connection, queue_id: str, status: str, origin_prefix: Optional[str] + self, + conn: Connection, + queue_id: str, + status: str, + origin_prefix: Optional[str], + owner_id: Optional[str] = None, ) -> Optional[Row[Any]]: """The `pending` item that runs next (highest priority, then oldest), or the `in_progress` one first - claimed.""" - statement = _item_by_status(status, origin_prefix is not None) - parameters = {"queue_id": queue_id, "origin_pattern": _origin_pattern(origin_prefix)} + claimed; of the account's items only, with `owner_id`.""" + statement = _item_by_status(status, origin_prefix is not None, owner_id is not None) + parameters = {"queue_id": queue_id, "origin_pattern": _origin_pattern(origin_prefix), "owner_id": owner_id} return _row_of(conn, conn.execute(statement, parameters).scalar()) @read @@ -814,16 +826,16 @@ def item_ids( queue_id: str, *, descending: bool, - user_id: Optional[str], + owner_id: Optional[str], origin_prefix: Optional[str], limit: Optional[int] = None, ) -> list[int]: """The ids of the queue's items (the account's, with the origin, if given) by creation time; with `limit`, the first `limit` of that order.""" - statement = _item_ids(descending, user_id is not None, origin_prefix is not None, limit is not None) + statement = _item_ids(descending, owner_id is not None, origin_prefix is not None, limit is not None) parameters = { "queue_id": queue_id, - "user_id": user_id, + "owner_id": owner_id, "origin_pattern": _origin_pattern(origin_prefix), "limit": limit, } @@ -837,12 +849,24 @@ def summaries(self, conn: Connection, queue_id: str, item_ids: Sequence[int]) -> @read def status_counts( - self, conn: Connection, queue_id: str, user_id: Optional[str], origin_prefix: Optional[str] + self, + conn: Connection, + queue_id: str, + user_id: Optional[str], + origin_prefix: Optional[str], + owner_id: Optional[str] = None, ) -> StatusCounts: - by_origin = origin_prefix is not None - parameters = {"queue_id": queue_id, "user_id": user_id, "origin_pattern": _origin_pattern(origin_prefix)} - rows = conn.execute(_status_counts(user_id is not None, by_origin), parameters).all() - current = conn.execute(_current(by_origin), parameters).first() + """The counts and the item in progress, of the account's items only with `owner_id`; `user_id`'s share of + them besides.""" + by_origin, by_owner = origin_prefix is not None, owner_id is not None + parameters = { + "queue_id": queue_id, + "user_id": user_id, + "origin_pattern": _origin_pattern(origin_prefix), + "owner_id": owner_id, + } + rows = conn.execute(_status_counts(user_id is not None, by_origin, by_owner), parameters).all() + current = conn.execute(_current(by_origin, by_owner), parameters).first() return StatusCounts( counts={str(row[0]): int(row[1]) for row in rows}, own_counts={str(row[0]): int(row[2]) for row in rows if row[2]}, diff --git a/invokeai/app/services/shared/database/schema/boards.py b/invokeai/app/services/shared/database/schema/boards.py index 96c0737bb31..7c57d45e6f1 100644 --- a/invokeai/app/services/shared/database/schema/boards.py +++ b/invokeai/app/services/shared/database/schema/boards.py @@ -1,9 +1,8 @@ """Boards, who they are shared with, and the media on them.""" -from sqlalchemy import Boolean, Column, ForeignKey, Index +from sqlalchemy import Boolean, CheckConstraint, Column, ForeignKey, Index from invokeai.app.services.shared.database.schema.metadata import ( - ENUM_LENGTH, USER_ID_LENGTH, default, inserted_at, @@ -21,29 +20,33 @@ updated_at(), Column("deleted_at", Timestamp()), # unused Column("archived", Boolean(), server_default=default(False)), + # The owner. No foreign key: a deleted account's boards stay, for admins to see and manage. Column("user_id", Key(USER_ID_LENGTH), server_default=default("system")), - Column("is_public", Boolean(), nullable=False, server_default=default(False)), - Column("board_visibility", Key(ENUM_LENGTH), nullable=False, server_default=default("private")), + # The project the board lives in; NULL is the Library. Not a foreign key: `projects.board_id` already refers + # here, and a key each way leaves no order to create or copy the tables in. Project and account deletion + # clear it instead. Column("project_id", Key()), + # The role every other account has on the board: 'restricted' (none), 'viewer' or 'contributor'. + Column("general_access", LongText(), nullable=False, server_default=default("restricted")), + CheckConstraint("general_access IN ('restricted', 'viewer', 'contributor')", name="general_access"), ) -Index("idx_boards_board_visibility", boards.c.board_visibility) Index("idx_boards_created_at", boards.c.created_at) -Index("idx_boards_is_public", boards.c.is_public) Index("idx_boards_user_id", boards.c.user_id) Index("idx_boards_project_id", boards.c.project_id) -shared_boards = table( - "shared_boards", +# The accounts a board is shared with, and their role. The owner is never a member: `boards.user_id` is. +board_members = table( + "board_members", Column("board_id", Key(), ForeignKey("boards.board_id", ondelete="CASCADE"), primary_key=True), Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True), - Column("can_edit", Boolean(), nullable=False, server_default=default(False)), - inserted_at("shared_at"), + Column("role", LongText(), nullable=False), + Column("granted_by", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="SET NULL")), + inserted_at("granted_at"), + CheckConstraint("role IN ('viewer', 'contributor', 'editor')", name="role"), ) -# The primary key covers it. Only SQLite has it, where a migration created it. -Index("idx_shared_boards_board_id", shared_boards.c.board_id).ddl_if(dialect="sqlite") -Index("idx_shared_boards_user_id", shared_boards.c.user_id) +Index("idx_board_members_user_id", board_members.c.user_id) # An image is on at most one board: its name is the primary key. board_images = table( diff --git a/invokeai/app/services/shared/database/schema/media_references.py b/invokeai/app/services/shared/database/schema/media_references.py index 47b8a76d840..b5f6c2734aa 100644 --- a/invokeai/app/services/shared/database/schema/media_references.py +++ b/invokeai/app/services/shared/database/schema/media_references.py @@ -9,7 +9,7 @@ "media_references", # A MediaReferenceOwnerKind. Column("owner_kind", Key(ENUM_LENGTH), primary_key=True), - # The owning account: project ids are unique per user, not globally. + # The owning account. Column("user_id", Key(USER_ID_LENGTH), primary_key=True), # A project id, a workflow id or a client state key. Column("owner_id", Key(), primary_key=True), diff --git a/invokeai/app/services/shared/database/schema/projects.py b/invokeai/app/services/shared/database/schema/projects.py index 06014242517..aea6b8a559d 100644 --- a/invokeai/app/services/shared/database/schema/projects.py +++ b/invokeai/app/services/shared/database/schema/projects.py @@ -1,6 +1,6 @@ -"""Canvas projects, and the documents of projects that lost their owner.""" +"""Canvas projects, each member's workspace in them, and the documents of projects that lost their owner.""" -from sqlalchemy import CheckConstraint, Column, ForeignKey, PrimaryKeyConstraint +from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, PrimaryKeyConstraint from invokeai.app.services.shared.database.schema.metadata import ( USER_ID_LENGTH, @@ -13,11 +13,12 @@ projects = table( "projects", - # Generated by the client; unique per user, not globally. + # Generated by the client; unique across accounts. Column("project_id", Key(), nullable=False), + # The owner. Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False), Column("name", LongText(), nullable=False), - # The client's project document (JSON). + # The client's project content (JSON); each member's workspace is in `project_workspaces`. Column("data", LongText(), nullable=False), # The project's own board. Unique: at most one project per board. RESTRICT: a claimed board cannot be deleted. Column("board_id", Key(), ForeignKey("boards.board_id", ondelete="RESTRICT"), nullable=False, unique=True), @@ -26,10 +27,29 @@ inserted_at(), updated_at(), Column("minimum_canvas_schema_version", BigInt(), nullable=False, server_default=default(2)), - PrimaryKeyConstraint("user_id", "project_id"), + # The oldest project document schema that can read `data`: a client that understands less is refused it. + Column("minimum_document_schema_version", BigInt(), nullable=False, server_default=default(3)), + PrimaryKeyConstraint("project_id"), CheckConstraint("minimum_canvas_schema_version >= 1", name="minimum_canvas_schema_version"), + CheckConstraint("minimum_document_schema_version >= 1", name="minimum_document_schema_version"), ) +Index("idx_projects_user_id", projects.c.user_id) + +# One account's workspace in a project: its layout, widgets and generation settings (JSON), saved apart from the +# project content so that the two never conflict. +project_workspaces = table( + "project_workspaces", + Column("project_id", Key(), ForeignKey("projects.project_id", ondelete="CASCADE"), primary_key=True), + Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True), + Column("data", LongText(), nullable=False), + # Incremented on every update, for optimistic concurrency. + Column("revision", BigInt(), nullable=False, server_default=default(1)), + updated_at(), +) + +Index("idx_project_workspaces_user_id", project_workspaces.c.user_id) + # Where the project boards migration put projects whose owner no longer existed, whole, for repair. Free of # foreign keys, so it can hold what `projects` cannot. Created by that migration only when it had such projects. orphaned_projects_2026_08_06 = table( diff --git a/invokeai/app/services/shared/invocation_context.py b/invokeai/app/services/shared/invocation_context.py index ecfec580948..469e0ce0319 100644 --- a/invokeai/app/services/shared/invocation_context.py +++ b/invokeai/app/services/shared/invocation_context.py @@ -10,7 +10,15 @@ from invokeai.app.invocations.constants import IMAGE_MODES from invokeai.app.invocations.fields import MetadataField, WithBoard, WithMetadata -from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy, BoardVisibility +from invokeai.app.services.board_access.board_access_common import ( + BoardAccessDeniedError, + BoardAction, + BoardViewer, + ItemAccessDeniedError, + ItemAction, + queue_viewer, +) +from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException, BoardRecordOrderBy from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.config.config_default import InvokeAIAppConfig from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin @@ -89,6 +97,18 @@ def __init__(self, services: InvocationServices, data: InvocationContextData) -> self._services = services self._data = data + def _viewer(self, refusal: str) -> BoardViewer: + """The queue user, as the boards API sees a caller (see `queue_viewer`); a deleted or deactivated account is + refused.""" + viewer = queue_viewer( + self._data.queue_item.user_id, + multiuser=self._services.configuration.multiuser, + users=self._services.users, + ) + if viewer is None: + raise PermissionError(refusal) + return viewer + def _get_workflow_json(self) -> str | None: queue_item = self._data.queue_item if not queue_item._workflow_json_loaded: @@ -115,6 +135,12 @@ def _build_execution_effects(data: InvocationContextData) -> ExecutionEffectsRec class BoardsInterface(InvocationContextInterface): + def _require(self, board_id: str, viewer: BoardViewer, action: BoardAction, refusal: str) -> None: + try: + self._services.board_access.require(board_id, viewer.user_id, viewer.is_admin, action) + except BoardAccessDeniedError: + raise PermissionError(refusal) + def create(self, board_name: str) -> BoardDTO: """Creates a board for the current user. @@ -124,8 +150,8 @@ def create(self, board_name: str) -> BoardDTO: Returns: The created board DTO. """ - user_id = self._data.queue_item.user_id - return self._services.boards.create(board_name, user_id) + viewer = self._viewer("Queue user is not authorized to create boards") + return self._services.boards.create(board_name, viewer.user_id, viewer=viewer) def get_dto(self, board_id: str) -> BoardDTO: """Gets a board DTO. @@ -136,7 +162,10 @@ def get_dto(self, board_id: str) -> BoardDTO: Returns: The board DTO. """ - return self._services.boards.get_dto(board_id) + refusal = "Queue user is not authorized to access this board" + viewer = self._viewer(refusal) + self._require(board_id, viewer, BoardAction.Read, refusal) + return self._services.boards.get_dto(board_id, viewer=viewer) def get_all(self) -> list[BoardDTO]: """Gets all boards accessible to the current user. @@ -144,19 +173,9 @@ def get_all(self) -> list[BoardDTO]: Returns: A list of all boards accessible to the current user. """ - user_id = self._data.queue_item.user_id - # Mirrors the boards API: single-user mode runs as an admin, multiuser mode uses the queue user's role. - if not self._services.configuration.multiuser: - is_admin = True - else: - user = self._services.users.get(user_id) - # See ImagesInterface._assert_read_access: deactivated accounts keep no - # queue-time privileges, including an admin's view of other users' boards. - if user is not None and not user.is_active: - raise PermissionError("Queue user is not authorized to list boards") - is_admin = user is not None and user.is_admin + viewer = self._viewer("Queue user is not authorized to list boards") return self._services.boards.get_all( - user_id, is_admin, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending + viewer.user_id, viewer.is_admin, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending ) def add_image_to_board(self, board_id: str, image_name: str) -> None: @@ -166,6 +185,14 @@ def add_image_to_board(self, board_id: str, image_name: str) -> None: board_id: The ID of the board to add the image to. image_name: The name of the image to add to the board. """ + refusal = "Queue user is not authorized to add this image to this board" + viewer = self._viewer(refusal) + self._require(board_id, viewer, BoardAction.AddMedia, refusal) + # Only an image's owner may put it on a board, as through the boards API. + try: + self._services.board_access.require_image(image_name, viewer.user_id, viewer.is_admin, ItemAction.Edit) + except ItemAccessDeniedError: + raise PermissionError(refusal) return self._services.board_images.add_image_to_board(board_id, image_name) def get_all_image_names_for_board(self, board_id: str) -> list[str]: @@ -177,6 +204,9 @@ def get_all_image_names_for_board(self, board_id: str) -> list[str]: Returns: A list of all image names for the board. """ + refusal = "Queue user is not authorized to access this board" + viewer = self._viewer(refusal) + self._require(board_id, viewer, BoardAction.Read, refusal) return self._services.board_images.get_all_board_image_names_for_board( board_id, categories=None, @@ -224,23 +254,15 @@ def __init__(self, services: InvocationServices, data: InvocationContextData, ut self._util = util def _assert_read_access(self, image_name: str) -> None: + # Single-user mode reads any image: the queue runs as the one account. if not self._services.configuration.multiuser: return - user_id = self._data.queue_item.user_id - user = self._services.users.get(user_id) - # A deactivated or deleted account keeps no queue-time privileges: its - # queued graphs must not read media even if the item slipped past the - # processor's owner checks. - if user is None or not user.is_active: - raise PermissionError("Queue user is not authorized to access this image") - if user.is_admin or self._services.image_records.get_user_id(image_name) == user_id: - return - board_id = self._services.board_image_records.get_board_for_image(image_name) - if board_id is not None: - board = self._services.boards.get_dto(board_id) - if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): - return - raise PermissionError("Queue user is not authorized to access this image") + refusal = "Queue user is not authorized to access this image" + viewer = self._viewer(refusal) + try: + self._services.board_access.require_image(image_name, viewer.user_id, viewer.is_admin, ItemAction.Read) + except ItemAccessDeniedError: + raise PermissionError(refusal) def save( self, @@ -284,19 +306,17 @@ def save( elif isinstance(self._data.invocation, WithBoard) and self._data.invocation.board: board_id_ = self._data.invocation.board.board_id + # Single-user mode saves to any board: the queue runs as the one account. if self._services.configuration.multiuser: - user = self._services.users.get(self._data.queue_item.user_id) - # A deactivated or deleted account must not save outputs, even - # uncategorized ones — deactivation revokes queue-time privileges. - if user is None or not user.is_active: - raise PermissionError("Queue user is not authorized to save images") + # A deactivated or deleted account must not save outputs, even uncategorized ones. + viewer = self._viewer("Queue user is not authorized to save images") if board_id_ is not None: - board = self._services.boards.get_dto(board_id_) - if ( - not user.is_admin - and board.user_id != self._data.queue_item.user_id - and board.board_visibility != BoardVisibility.Public - ): + # Checked when the output is saved, against the role the user has now. + try: + self._services.board_access.require( + board_id_, viewer.user_id, viewer.is_admin, BoardAction.AddMedia + ) + except (BoardAccessDeniedError, BoardRecordNotFoundException): raise PermissionError("Queue user is not authorized to save images to this board") workflow_ = self._get_workflow_json() @@ -397,22 +417,15 @@ def __init__(self, services: InvocationServices, data: InvocationContextData, ut self._util = util def _assert_read_access(self, video_name: str) -> None: + # See ImagesInterface._assert_read_access. if not self._services.configuration.multiuser: return - user_id = self._data.queue_item.user_id - user = self._services.users.get(user_id) - # See ImagesInterface._assert_read_access: deactivated accounts keep no - # queue-time privileges. - if user is None or not user.is_active: - raise PermissionError("Queue user is not authorized to access this video") - if user.is_admin or self._services.video_records.get_user_id(video_name) == user_id: - return - board_id = self._services.board_video_records.get_board_for_video(video_name) - if board_id is not None: - board = self._services.boards.get_dto(board_id) - if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): - return - raise PermissionError("Queue user is not authorized to access this video") + refusal = "Queue user is not authorized to access this video" + viewer = self._viewer(refusal) + try: + self._services.board_access.require_video(video_name, viewer.user_id, viewer.is_admin, ItemAction.Read) + except ItemAccessDeniedError: + raise PermissionError(refusal) def save( self, @@ -449,18 +462,16 @@ def save( elif isinstance(self._data.invocation, WithBoard) and self._data.invocation.board: board_id_ = self._data.invocation.board.board_id + # See ImagesInterface.save. if self._services.configuration.multiuser: - user = self._services.users.get(self._data.queue_item.user_id) - # See ImagesInterface.save: deactivated accounts must not save outputs. - if user is None or not user.is_active: - raise PermissionError("Queue user is not authorized to save videos") + viewer = self._viewer("Queue user is not authorized to save videos") if board_id_ is not None: - board = self._services.boards.get_dto(board_id_) - if ( - not user.is_admin - and board.user_id != self._data.queue_item.user_id - and board.board_visibility != BoardVisibility.Public - ): + # Checked when the output is saved, against the role the user has now. + try: + self._services.board_access.require( + board_id_, viewer.user_id, viewer.is_admin, BoardAction.AddMedia + ) + except (BoardAccessDeniedError, BoardRecordNotFoundException): raise PermissionError("Queue user is not authorized to save videos to this board") workflow_ = self._get_workflow_json() diff --git a/invokeai/app/services/shared/media_references.py b/invokeai/app/services/shared/media_references.py index 5367a240615..9f4fb4d3527 100644 --- a/invokeai/app/services/shared/media_references.py +++ b/invokeai/app/services/shared/media_references.py @@ -20,7 +20,7 @@ from dataclasses import dataclass, field from typing import Literal -MediaReferenceOwnerKind = Literal["project", "workflow", "client_state", "quarantined_project"] +MediaReferenceOwnerKind = Literal["project", "project_workspace", "workflow", "client_state", "quarantined_project"] MediaKind = Literal["image", "video"] IMAGE_NAME_KEYS = frozenset({"imageName", "image_name"}) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_08_add_board_project_membership.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_08_add_board_project_membership.py index 8a80eff69bb..39c6328a0d8 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_08_add_board_project_membership.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_08_add_board_project_membership.py @@ -45,6 +45,6 @@ def _add_board_project_membership(context: PortableMigrationContext) -> None: def build_migration() -> PortableMigration: return PortableMigration( id="2026_10_08_add_board_project_membership", - depends_on="2026_10_07_add_session_queue_admission_lock", + depends_on="2026_10_07_drop_sqlite_triggers", callback=_add_board_project_membership, ) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py new file mode 100644 index 00000000000..1e9803693ce --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py @@ -0,0 +1,135 @@ +"""Share boards by role: members with a role each, and the role everyone else gets. + +`board_members` holds the accounts a board is shared with and their role (viewer, contributor or editor); the +owner is never a member. `boards.general_access` is the role every other account gets: none ('restricted'), +'viewer' or 'contributor'. They replace `board_visibility`, the `is_public` flag it superseded, and +`shared_boards`, which no code ever wrote and whose `can_edit` nothing ever read. + +Visibility maps to general access: private to restricted, shared to viewer, public to contributor. Each +`shared_boards` row becomes a viewer member, whatever its `can_edit`, unless it names the board's own owner. A +board without an owner (`user_id` NULL, as some left by a deleted account are) keeps every share. +""" + +from sqlalchemy import ( + CheckConstraint, + Column, + ForeignKey, + Table, + case, + column, + exists, + inspect, + literal, + null, + or_, + select, + table, +) + +from invokeai.app.services.shared.database.schema.metadata import USER_ID_LENGTH, default, inserted_at +from invokeai.app.services.shared.database.types import Key, LongText +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( + PortableMigration, + PortableMigrationContext, +) + +_GENERAL_ACCESS_CHECK = "general_access IN ('restricted', 'viewer', 'contributor')" + +_boards = table("boards", column("board_id"), column("user_id"), column("board_visibility"), column("general_access")) +_shared_boards = table("shared_boards", column("board_id"), column("user_id"), column("shared_at")) +_board_members = table( + "board_members", column("board_id"), column("user_id"), column("role"), column("granted_by"), column("granted_at") +) + + +def _create_board_members(context: PortableMigrationContext) -> None: + # The tables its foreign keys name, as far as the CREATE TABLE needs them. + Table("boards", context.metadata, Column("board_id", Key(), primary_key=True)) + Table("users", context.metadata, Column("user_id", Key(USER_ID_LENGTH), primary_key=True)) + context.create_table( + "board_members", + Column("board_id", Key(), ForeignKey("boards.board_id", ondelete="CASCADE"), primary_key=True), + Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True), + Column("role", LongText(), nullable=False), + Column("granted_by", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="SET NULL")), + inserted_at("granted_at"), + CheckConstraint("role IN ('viewer', 'contributor', 'editor')", name="role"), + ) + + +def _copy_shared_boards(context: PortableMigrationContext) -> None: + """Every share becomes a viewer member. Rows already copied by an interrupted run are skipped.""" + already_member = exists().where( + _board_members.c.board_id == _shared_boards.c.board_id, _board_members.c.user_id == _shared_boards.c.user_id + ) + shares = ( + select( + _shared_boards.c.board_id, _shared_boards.c.user_id, literal("viewer"), null(), _shared_boards.c.shared_at + ) + .select_from(_shared_boards.join(_boards, _boards.c.board_id == _shared_boards.c.board_id)) + # `!=` alone is NULL, and so false, for a board without an owner. + .where(or_(_boards.c.user_id.is_(None), _shared_boards.c.user_id != _boards.c.user_id), ~already_member) + ) + context.conn.execute( + _board_members.insert().from_select(["board_id", "user_id", "role", "granted_by", "granted_at"], shares) + ) + + +def _board_roles(context: PortableMigrationContext) -> None: + inspector = inspect(context.conn) + tables = set(inspector.get_table_names()) + + if "board_members" not in tables: + _create_board_members(context) + if "shared_boards" in tables: + _copy_shared_boards(context) + + columns = {entry["name"] for entry in inspect(context.conn).get_columns("boards")} + if "general_access" not in columns: + context.op.add_column( + "boards", Column("general_access", LongText(), nullable=False, server_default=default("restricted")) + ) + if "board_visibility" in columns: + # Repeated by a run that resumes before the column is dropped, with the same result. + context.conn.execute( + _boards.update().values( + general_access=case( + (_boards.c.board_visibility == "shared", "viewer"), + (_boards.c.board_visibility == "public", "contributor"), + else_="restricted", + ) + ) + ) + + indexes = {index["name"] for index in inspect(context.conn).get_indexes("boards")} + for name in ("idx_boards_board_visibility", "idx_boards_is_public"): + if name in indexes: + context.op.drop_index(name, table_name="boards") + + columns = {entry["name"] for entry in inspect(context.conn).get_columns("boards")} + has_check = "ck_boards_general_access" in { + check["name"] for check in inspect(context.conn).get_check_constraints("boards") + } + dropped = [name for name in ("board_visibility", "is_public") if name in columns] + if dropped or not has_check: + # One rebuild on SQLite for every change; plain ALTERs on a server. + with context.op.batch_alter_table("boards") as batch: + for name in dropped: + batch.drop_column(name) + if not has_check: + batch.create_check_constraint("general_access", _GENERAL_ACCESS_CHECK) + + if "idx_board_members_user_id" not in { + index["name"] for index in inspect(context.conn).get_indexes("board_members") + }: + context.op.create_index("idx_board_members_user_id", "board_members", ["user_id"]) + if "shared_boards" in set(inspect(context.conn).get_table_names()): + context.op.drop_table("shared_boards") + + +def build_migration() -> PortableMigration: + return PortableMigration( + id="2026_10_10_board_roles", + depends_on="2026_10_08_add_board_project_membership", + callback=_board_roles, + ) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_global_project_ids.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_global_project_ids.py new file mode 100644 index 00000000000..6194850a53a --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_global_project_ids.py @@ -0,0 +1,224 @@ +"""Key projects by `project_id` alone, and clear board memberships that name no project. + +Project ids were unique per account; they become unique across accounts so that a project can be named without +its owner, which sharing needs. Clients have always generated them at random, so two accounts holding the same +id is not expected; where it happens anyway, the oldest project keeps the id and every other one gets a fresh +`project-`, with everything that names it rewritten: its boards' membership, the provenance of its images +and videos, its queue items and their origins, its media references, and the open-project state the web client +keeps for its owner. Each re-keyed project is logged. + +Boards of accounts deleted earlier still name projects that went with the account; those memberships are +cleared, which leaves the boards in their owner's Library, and their number is logged. From now on deleting an +account clears its boards' memberships with it. + +All data changes run before the first DDL: on a server every DDL statement commits on its own, and a run that +fails among the data changes then rolls them back whole instead of leaving half a project re-keyed. Re-keying +runs only while `projects` is still keyed per account, so a resumed run never re-keys twice. +""" + +import json +from functools import partial +from typing import Any +from uuid import uuid4 + +from sqlalchemy import ( + CheckConstraint, + Column, + ForeignKey, + MetaData, + PrimaryKeyConstraint, + Table, + column, + exists, + func, + inspect, + select, + table, +) + +from invokeai.app.services.shared.database.schema.metadata import USER_ID_LENGTH, default, inserted_at, updated_at +from invokeai.app.services.shared.database.types import BigInt, Key, LongText +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( + PortableMigration, + PortableMigrationContext, +) + +_PER_ACCOUNT_TABLE = "projects_per_account" +_ORIGIN_PREFIX = "webv2:p:" +# As the application and the web client serialize these documents: compact, with non-ASCII text as itself. A +# document near the size limit, serialized any wider, would come out over it. +_dumps = partial(json.dumps, ensure_ascii=False, allow_nan=False, separators=(",", ":")) +# Client state is the web client's own string, stored as sent and never bounded: a draft cut mid-emoji arrives as +# the escape `\ud83d`, which parses to a lone surrogate that no UTF-8 column can hold as itself. Escaped, every +# non-ASCII character is written as an escape, so the text differs from the client's but reads as the same value +# to any JSON reader; the value has no size limit a wider serialization could cross. +_dumps_client_state = partial(json.dumps, ensure_ascii=True, allow_nan=False, separators=(",", ":")) + +_projects = table("projects", column("project_id"), column("user_id"), column("created_at"), column("data")) +_boards = table("boards", column("board_id"), column("user_id"), column("project_id")) +_images = table("images", column("user_id"), column("project_id")) +_videos = table("videos", column("user_id"), column("project_id")) +_session_queue = table("session_queue", column("item_id"), column("user_id"), column("project_id"), column("origin")) +_media_references = table("media_references", column("owner_kind"), column("user_id"), column("owner_id")) +_client_state = table("client_state", column("user_id"), column("key"), column("value")) + + +def _is_keyed_by_project_id(context: PortableMigrationContext) -> bool: + return inspect(context.conn).get_pk_constraint("projects")["constrained_columns"] == ["project_id"] + + +def _replace_id(value: Any, old_id: str, new_id: str) -> Any: + """The JSON value with every string equal to the old id, and every key equal to it, replaced.""" + if isinstance(value, str): + return new_id if value == old_id else value + if isinstance(value, list): + return [_replace_id(item, old_id, new_id) for item in value] + if isinstance(value, dict): + return {(new_id if key == old_id else key): _replace_id(item, old_id, new_id) for key, item in value.items()} + return value + + +def _rekey(context: PortableMigrationContext, user_id: str, old_id: str, new_id: str) -> None: + conn = context.conn + # The client's document names its project too; a client opening it under the old id would save to nothing. + document = conn.execute( + select(_projects.c.data).where(_projects.c.user_id == user_id, _projects.c.project_id == old_id) + ).scalar_one() + try: + rewritten_document = _dumps(_replace_id(json.loads(document), old_id, new_id)) + except ValueError: + rewritten_document = document + conn.execute( + _projects.update() + .where(_projects.c.user_id == user_id, _projects.c.project_id == old_id) + .values(data=rewritten_document) + ) + for target in (_projects, _boards, _images, _videos, _session_queue): + conn.execute( + target.update().where(target.c.user_id == user_id, target.c.project_id == old_id).values(project_id=new_id) + ) + old_prefix = f"{_ORIGIN_PREFIX}{old_id}:" + for item_id, origin in conn.execute( + select(_session_queue.c.item_id, _session_queue.c.origin).where( + _session_queue.c.user_id == user_id, _session_queue.c.origin.startswith(old_prefix, autoescape=True) + ) + ).all(): + conn.execute( + _session_queue.update() + .where(_session_queue.c.item_id == item_id) + .values(origin=f"{_ORIGIN_PREFIX}{new_id}:{origin[len(old_prefix) :]}") + ) + conn.execute( + _media_references.update() + .where( + _media_references.c.owner_kind == "project", + _media_references.c.user_id == user_id, + _media_references.c.owner_id == old_id, + ) + .values(owner_id=new_id) + ) + # The web client's open projects, active project and covers name projects by id. + for key, value in conn.execute( + select(_client_state.c.key, _client_state.c.value).where( + _client_state.c.user_id == user_id, _client_state.c.value.contains(old_id, autoescape=True) + ) + ).all(): + try: + document = json.loads(value) + rewritten = _replace_id(document, old_id, new_id) + rewritten_value = _dumps_client_state(rewritten) + except ValueError: + continue + if rewritten != document: + conn.execute( + _client_state.update() + .where(_client_state.c.user_id == user_id, _client_state.c.key == key) + .values(value=rewritten_value) + ) + + +def _rekey_duplicates(context: PortableMigrationContext) -> None: + shared_ids = ( + select(_projects.c.project_id).group_by(_projects.c.project_id).having(func.count() > 1).scalar_subquery() + ) + holders = context.conn.execute( + select(_projects.c.project_id, _projects.c.user_id) + .where(_projects.c.project_id.in_(shared_ids)) + .order_by(_projects.c.project_id, _projects.c.created_at, _projects.c.user_id) + ).all() + keeper: dict[str, str] = {} + for project_id, user_id in holders: + if keeper.setdefault(project_id, user_id) == user_id: + continue + new_id = f"project-{uuid4()}" + _rekey(context, user_id, project_id, new_id) + context.logger.warning( + f"Project id {project_id} was held by more than one account; the copy owned by {user_id} is now {new_id}" + ) + + +def _clear_unresolvable_memberships(context: PortableMigrationContext) -> None: + """Boards name projects of their own owner; a membership no such project resolves goes back to the Library.""" + resolves = exists().where(_projects.c.project_id == _boards.c.project_id, _projects.c.user_id == _boards.c.user_id) + cleared = context.conn.execute( + _boards.update().where(_boards.c.project_id.is_not(None), ~resolves).values(project_id=None) + ).rowcount + if cleared: + context.logger.warning(f"{cleared} boards named a project that no longer exists; they are in the Library now") + + +def _rebuild_projects_on_sqlite(context: PortableMigrationContext) -> None: + """SQLite changes a primary key only by rebuilding the table, inside the migration's transaction. The old table + is moved aside first, so the new one is created under its own name and its constraints are named for it. No + table refers to `projects` by foreign key, so the move rewrites none.""" + context.op.rename_table("projects", _PER_ACCOUNT_TABLE) + # The tables its foreign keys name, as far as the CREATE TABLE needs them. + Table("users", context.metadata, Column("user_id", Key(USER_ID_LENGTH), primary_key=True)) + Table("boards", context.metadata, Column("board_id", Key(), primary_key=True)) + rekeyed = context.create_table( + "projects", + Column("project_id", Key(), nullable=False), + Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False), + Column("name", LongText(), nullable=False), + Column("data", LongText(), nullable=False), + Column("board_id", Key(), ForeignKey("boards.board_id", ondelete="RESTRICT"), nullable=False, unique=True), + Column("revision", BigInt(), nullable=False, server_default=default(1)), + inserted_at(), + updated_at(), + Column("minimum_canvas_schema_version", BigInt(), nullable=False, server_default=default(2)), + PrimaryKeyConstraint("project_id"), + CheckConstraint("minimum_canvas_schema_version >= 1", name="minimum_canvas_schema_version"), + ) + old = Table(_PER_ACCOUNT_TABLE, MetaData(), autoload_with=context.conn) + names = [entry.name for entry in rekeyed.columns] + context.conn.execute(rekeyed.insert().from_select(names, select(*(old.c[name] for name in names)))) + context.op.drop_table(_PER_ACCOUNT_TABLE) + + +def _create_user_index(context: PortableMigrationContext) -> None: + if "idx_projects_user_id" not in {index["name"] for index in inspect(context.conn).get_indexes("projects")}: + context.op.create_index("idx_projects_user_id", "projects", ["user_id"]) + + +def _global_project_ids(context: PortableMigrationContext) -> None: + if not _is_keyed_by_project_id(context): + _rekey_duplicates(context) + _clear_unresolvable_memberships(context) + + if not _is_keyed_by_project_id(context): + if context.conn.dialect.name == "sqlite": + _rebuild_projects_on_sqlite(context) + else: + # The per-account key is also the index the `user_id` foreign key relies on, so the replacement comes + # first. One statement then swaps the key, so a failed run never leaves the table without one. + _create_user_index(context) + context.conn.exec_driver_sql("ALTER TABLE projects DROP PRIMARY KEY, ADD PRIMARY KEY (project_id)") + _create_user_index(context) + + +def build_migration() -> PortableMigration: + return PortableMigration( + id="2026_10_10_global_project_ids", + depends_on="2026_10_10_board_roles", + callback=_global_project_ids, + ) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_project_workspaces.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_project_workspaces.py new file mode 100644 index 00000000000..cb59273b873 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_project_workspaces.py @@ -0,0 +1,63 @@ +"""Give each account its own workspace in a project, and record which document schema a project needs. + +`project_workspaces` holds one account's layout, widgets and generation settings in a project, saved apart from +the project content in `projects.data` so that the two never conflict. Existing projects get no rows: the client +splits a project's document into content and workspace the first time it saves it. + +`projects.minimum_document_schema_version` is the oldest document schema that can read a project, as +`minimum_canvas_schema_version` is for the canvas. Existing projects are at 3; the split raises a project to 4, and +a client that understands less is refused it. +""" + +from sqlalchemy import Column, ForeignKey, Table, inspect + +from invokeai.app.services.shared.database.schema.metadata import USER_ID_LENGTH, default, updated_at +from invokeai.app.services.shared.database.types import BigInt, Key, LongText +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( + PortableMigration, + PortableMigrationContext, +) + +_FLOOR = "minimum_document_schema_version" + + +def _project_workspaces(context: PortableMigrationContext) -> None: + if "project_workspaces" not in set(inspect(context.conn).get_table_names()): + # The tables its foreign keys name, as far as the CREATE TABLE needs them. + Table("projects", context.metadata, Column("project_id", Key(), primary_key=True)) + Table("users", context.metadata, Column("user_id", Key(USER_ID_LENGTH), primary_key=True)) + context.create_table( + "project_workspaces", + Column("project_id", Key(), ForeignKey("projects.project_id", ondelete="CASCADE"), primary_key=True), + Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True), + Column("data", LongText(), nullable=False), + Column("revision", BigInt(), nullable=False, server_default=default(1)), + updated_at(), + ) + if "idx_project_workspaces_user_id" not in { + index["name"] for index in inspect(context.conn).get_indexes("project_workspaces") + }: + context.op.create_index("idx_project_workspaces_user_id", "project_workspaces", ["user_id"]) + + if _FLOOR not in {entry["name"] for entry in inspect(context.conn).get_columns("projects")}: + if context.conn.dialect.name == "sqlite": + # SQLite adds a column with its CHECK in place; Alembic would copy the whole table to add the CHECK. + context.conn.exec_driver_sql( + f"ALTER TABLE projects ADD COLUMN {_FLOOR} INTEGER NOT NULL DEFAULT 3 " + f"CONSTRAINT ck_projects_{_FLOOR} CHECK ({_FLOOR} >= 1)" + ) + else: + context.op.add_column("projects", Column(_FLOOR, BigInt(), nullable=False, server_default=default(3))) + if f"ck_projects_{_FLOOR}" not in { + check["name"] for check in inspect(context.conn).get_check_constraints("projects") + }: + # On a server the CHECK is a statement of its own, which a failed run may not have reached. + context.op.create_check_constraint(_FLOOR, "projects", f"{_FLOOR} >= 1") + + +def build_migration() -> PortableMigration: + return PortableMigration( + id="2026_10_10_project_workspaces", + depends_on="2026_10_10_global_project_ids", + callback=_project_workspaces, + ) diff --git a/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py index 162cb29f02d..beb92750421 100644 --- a/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py +++ b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py @@ -391,7 +391,11 @@ def _apply_portable_migration(self, migration: PortableMigration) -> None: ) migration.callback( PortableMigrationContext( - conn=conn, op=Operations(MigrationContext.configure(conn)), logger=self._logger + conn=conn, + # The schema metadata's naming convention names what the operations create, as it names what + # the metadata creates. + op=Operations(MigrationContext.configure(conn, opts={"target_metadata": metadata})), + logger=self._logger, ) ) self._check_foreign_keys(conn, violations_before) diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py index 29094af9762..755aeb306bd 100644 --- a/invokeai/app/services/users/users_default.py +++ b/invokeai/app/services/users/users_default.py @@ -154,11 +154,17 @@ def delete(self, user_id: str) -> None: def delete_user(q: Queries) -> None: q.locks.acquire(DatabaseLock.ADMIN_ACCOUNTS) self._assert_not_last_admin(q, user_id, is_deleting=True) + # Read first: the account's projects cascade with it, and every member's workspace in them. + workspaces = q.projects.workspaces_in_owned(user_id) q.users.delete(user_id) - # Projects and client state cascade with the account; the media references they held + # The account's projects cascade with it; its boards stay, for admins, in no project. + q.boards.release_owned(user_id) + # Projects, workspaces and client state cascade with the account; the media references they held # would otherwise protect a departed account's intermediates from every cleanup forever. # Workflows and quarantined projects do not cascade, so their references stay with them. - q.media_references.delete_owned_by(user_id, "project", "client_state") + q.media_references.delete_owned_by(user_id, "project", "project_workspace", "client_state") + for member, project_id in workspaces: + q.media_references.delete(owner_kind="project_workspace", user_id=member, owner_id=project_id) self._queries.run(delete_user) diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index c6c7044b778..311280d66b8 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -11096,7 +11096,7 @@ "patch": { "tags": ["boards"], "summary": "Update Board", - "description": "Updates a board (user must have access to it).\n\nA project's inbox takes its name, archived state and visibility from the project and cannot be\nmoved, so those changes are refused for it \u2014 for admins too; its cover is still fair game. A\nboard in a project is private and unshared, so a move into a project, or a visibility change on\na member, is refused when the result would be otherwise. The storage decides all of this in one\ntransaction; the DTO read here is only for the ownership check.", + "description": "Updates a board. Each field takes its own board action: renaming, setting the cover and archiving need an\neditor; general access and moving between the Library and a project need the owner.\n\nA project's inbox takes its name, archived state and general access from the project and cannot be moved,\nso those changes are refused for it \u2014 for admins too; its cover is still fair game. The storage decides\nthat in one transaction.", "operationId": "update_board", "security": [ { @@ -11153,7 +11153,7 @@ "delete": { "tags": ["boards"], "summary": "Delete Board", - "description": "Deletes a board (user must have access to it)", + "description": "Deletes a board: its owner's, or an admin's, to do", "operationId": "delete_board", "security": [ { @@ -12754,7 +12754,7 @@ "get": { "tags": ["queue"], "summary": "Get Queue Item Ids", - "description": "Gets the queue item ids that match the given parameters, ordered by creation time.\n\n`total_count` is the number of ids in `item_ids`. Without `limit`, every matching id is returned, so it\nequals the number of matching items. With `limit`, only the first `limit` ids of the same order are\nreturned, and it is not a total (counting every match would read the whole queue history); use the queue\nstatus for totals.\n\nIDs for every user's items are returned (item ids carry no sensitive data on their own).\nWhen the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging\nto other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see\npartially-redacted entries for other users' jobs in the queue list, while still revealing\nonly timestamps and status for items they do not own.\n\ncurrent_user is required so the endpoint stays behind authentication in multiuser mode.", + "description": "Gets the queue item ids that match the given parameters, ordered by creation time.\n\n`total_count` is the number of ids in `item_ids`. Without `limit`, every matching id is returned, so it\nequals the number of matching items. With `limit`, only the first `limit` ids of the same order are\nreturned, and it is not a total (counting every match would read the whole queue history); use the queue\nstatus for totals.\n\nWithout `origin_prefix`, IDs for every user's items are returned (item ids carry no sensitive data on their\nown); with it, a non-admin gets only their own.\nWhen the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging\nto other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see\npartially-redacted entries for other users' jobs in the queue list, while still revealing\nonly timestamps and status for items they do not own.\n\ncurrent_user is required so the endpoint stays behind authentication in multiuser mode.", "operationId": "get_queue_item_ids", "security": [ { @@ -12797,10 +12797,10 @@ "type": "null" } ], - "description": "Only include queue items whose origin starts with this prefix", + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match", "title": "Origin Prefix" }, - "description": "Only include queue items whose origin starts with this prefix" + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match" }, { "name": "limit", @@ -13559,10 +13559,10 @@ "type": "null" } ], - "description": "Only include queue items whose origin starts with this prefix", + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match", "title": "Origin Prefix" }, - "description": "Only include queue items whose origin starts with this prefix" + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match" } ], "responses": { @@ -13691,10 +13691,10 @@ "type": "null" } ], - "description": "Only include queue items whose origin starts with this prefix", + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match", "title": "Origin Prefix" }, - "description": "Only include queue items whose origin starts with this prefix" + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match" } ], "responses": { @@ -13739,7 +13739,7 @@ "get": { "tags": ["queue"], "summary": "Get Queue Status", - "description": "Gets the status of the session queue. Returns global counts; every user additionally gets\ntheir own pending/in_progress counts (so the UI can show an X/Y badge and scope personal UI\nlike the progress bar to the user's own activity). Non-admin users cannot see the current\nitem's identifiers unless they own it.", + "description": "Gets the status of the session queue. Returns global counts (a non-admin's counts are their own when\n`origin_prefix` is given); every user additionally gets their own pending/in_progress counts (so the UI\ncan show an X/Y badge and scope personal UI like the progress bar to the user's own activity). Non-admin\nusers cannot see the current item's identifiers unless they own it.", "operationId": "get_queue_status", "security": [ { @@ -13771,10 +13771,10 @@ "type": "null" } ], - "description": "Only include queue items whose origin starts with this prefix", + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match", "title": "Origin Prefix" }, - "description": "Only include queue items whose origin starts with this prefix" + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match" } ], "responses": { @@ -16286,7 +16286,7 @@ "post": { "tags": ["projects"], "summary": "Create Project", - "description": "Creates a project, and the private inbox board it owns, for the current user.", + "description": "Creates a project, and the private inbox board it owns, for the current user, and with it, when the request\ncarries `workspace`, the caller's workspace in it, in one transaction. Returns the record with the workspace\nthis create stored (null when it carried none).", "operationId": "create_project", "requestBody": { "content": { @@ -16305,7 +16305,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRecordDTO" + "$ref": "#/components/schemas/ProjectWithWorkspaceDTO" } } } @@ -16332,7 +16332,7 @@ "get": { "tags": ["projects"], "summary": "Get Project", - "description": "Gets one of the current user's projects, including its document.", + "description": "Gets one of the current user's projects: its content and the caller's workspace in it.", "operationId": "get_project", "security": [ { @@ -16363,6 +16363,19 @@ "title": "Max Canvas Schema Version" }, "description": "Newest canvas schema understood by this client" + }, + { + "name": "max_document_schema_version", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "Newest project document schema understood by this client", + "default": 3, + "title": "Max Document Schema Version" + }, + "description": "Newest project document schema understood by this client" } ], "responses": { @@ -16371,7 +16384,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRecordDTO" + "$ref": "#/components/schemas/ProjectWithWorkspaceDTO" } } } @@ -16391,7 +16404,7 @@ "put": { "tags": ["projects"], "summary": "Update Project", - "description": "Saves a project. Returns 409 with the current revision when the save is based on a stale revision.", + "description": "Saves a project's content and, when the request carries `workspace`, the caller's workspace in it, in one\ntransaction. Returns the record with the workspace this save stored (null when it carried none). Returns 409\nwith the current revision when the save is based on a stale revision of either.", "operationId": "update_project", "security": [ { @@ -16428,7 +16441,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRecordDTO" + "$ref": "#/components/schemas/ProjectWithWorkspaceDTO" } } } @@ -16498,6 +16511,65 @@ } } }, + "/api/v1/projects/{project_id}/workspace": { + "put": { + "tags": ["projects"], + "summary": "Save Project Workspace", + "description": "Saves the caller's workspace in one of their projects: their layout, widgets and generation settings, apart\nfrom the project content, so that saving one never conflicts with the other. Returns 409 with the current\nrevision (null when there is none) when the save is based on another, and 400 `workspace_needs_split_document`\nwhile the project is still held whole: the workspace is saved only beside content at the split schema.", + "operationId": "save_project_workspace", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the project whose workspace to save", + "title": "Project Id" + }, + "description": "The id of the project whose workspace to save" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectWorkspaceSaveRequest", + "description": "The workspace and the revision it is based on" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectWorkspaceDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/projects/{project_id}/board-snapshot": { "get": { "tags": ["projects"], @@ -21531,6 +21603,22 @@ "$ref": "#/components/schemas/LatentsOutput" } }, + "BoardAction": { + "type": "string", + "enum": [ + "read", + "add_media", + "manage_items", + "rename", + "set_cover", + "archive", + "set_general_access", + "move", + "delete" + ], + "title": "BoardAction", + "description": "What a caller can do to a board as a whole." + }, "BoardChanges": { "properties": { "board_name": { @@ -21571,6 +21659,17 @@ "title": "Archived", "description": "Whether or not the board is archived" }, + "general_access": { + "anyOf": [ + { + "$ref": "#/components/schemas/GeneralAccess" + }, + { + "type": "null" + } + ], + "description": "The role every other account gets on the board. Only its owner may change it." + }, "board_visibility": { "anyOf": [ { @@ -21580,7 +21679,8 @@ "type": "null" } ], - "description": "The visibility of the board." + "description": "Deprecated, to be removed in 8.0: general access under its old name. Send `general_access`.", + "deprecated": true }, "project_id": { "anyOf": [ @@ -21675,10 +21775,10 @@ "title": "Archived", "description": "Whether or not the board is archived." }, - "board_visibility": { - "$ref": "#/components/schemas/BoardVisibility", - "description": "The visibility of the board.", - "default": "private" + "general_access": { + "$ref": "#/components/schemas/GeneralAccess", + "description": "The role every other account gets on the board: none (restricted), viewer or contributor.", + "default": "restricted" }, "project_id": { "anyOf": [ @@ -21736,7 +21836,38 @@ } ], "title": "Owner Username", - "description": "The username of the board owner (for admin view)." + "description": "The board owner's display name. Admins get the email of an owner without one; other callers get 'Unknown user'. Null when the owner's account no longer exists." + }, + "board_visibility": { + "$ref": "#/components/schemas/BoardVisibility", + "description": "Deprecated, to be removed in 8.0: `general_access` under its old name.", + "default": "private", + "deprecated": true + }, + "my_role": { + "anyOf": [ + { + "$ref": "#/components/schemas/BoardRole" + }, + { + "type": "null" + } + ], + "description": "The caller's own role on the board; null when only admin rights give access." + }, + "allowed_actions": { + "items": { + "$ref": "#/components/schemas/BoardAction" + }, + "type": "array", + "title": "Allowed Actions", + "description": "The board actions the caller may take. Gate interfaces on these." + }, + "admin_override": { + "type": "boolean", + "title": "Admin Override", + "description": "Whether admin rights give the caller more than `my_role` does.", + "default": false }, "is_inbox": { "type": "boolean", @@ -21779,11 +21910,17 @@ "title": "BoardRecordOrderBy", "description": "The order by options for board records" }, + "BoardRole": { + "type": "string", + "enum": ["viewer", "contributor", "editor", "owner"], + "title": "BoardRole", + "description": "A caller's role on a board. Each role allows everything the roles before it allow." + }, "BoardVisibility": { "type": "string", "enum": ["private", "shared", "public"], "title": "BoardVisibility", - "description": "The visibility options for a board." + "description": "Deprecated: a board's general access under its pre-7.0 name. Kept until 8.0 for older clients." }, "Body_acknowledge_enqueue_batch": { "properties": { @@ -42913,6 +43050,12 @@ "title": "Gemma4Encoder_Gemma4Encoder_LTX2_Config", "description": "The LTX-2.5 Gemma-4-12B text encoder as a folder (config, tokenizer, one weight file)." }, + "GeneralAccess": { + "type": "string", + "enum": ["restricted", "viewer", "contributor"], + "title": "GeneralAccess", + "description": "The role every other account gets on a board: none, viewer or contributor." + }, "GeneratePasswordResponse": { "properties": { "password": { @@ -51215,39 +51358,6 @@ "description": "The board the media was added to, if any", "title": "Board Id" }, - "board_owner_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The owner of that board, who may differ from the uploader; None for no board", - "title": "Board Owner Id" - }, - "board_visibility": { - "anyOf": [ - { - "$ref": "#/components/schemas/BoardVisibility" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The visibility of that board; None when the media landed on no board" - }, - "shared_user_ids": { - "description": "Users a private board is explicitly shared with, who can see this media too", - "items": { - "type": "string" - }, - "title": "Shared User Ids", - "type": "array" - }, "image_name": { "description": "The name of the uploaded image", "title": "Image Name", @@ -51258,16 +51368,7 @@ "description": "The category of the uploaded image" } }, - "required": [ - "timestamp", - "user_id", - "board_id", - "board_owner_id", - "board_visibility", - "shared_user_ids", - "image_name", - "image_category" - ], + "required": ["timestamp", "user_id", "board_id", "image_name", "image_category"], "title": "ImageUploadedEvent", "type": "object" }, @@ -52480,9 +52581,9 @@ "properties": { "kind": { "type": "string", - "enum": ["project", "workflow", "client_state", "quarantined_project"], + "enum": ["project", "project_workspace", "workflow", "client_state", "quarantined_project"], "title": "Kind", - "description": "`client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair" + "description": "`project_workspace` is one account's workspace in a project; `client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair" }, "user_id": { "type": "string", @@ -92618,6 +92719,33 @@ "title": "Max Canvas Schema Version", "description": "Newest canvas schema understood by this client", "default": 2 + }, + "minimum_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Minimum Document Schema Version", + "description": "Oldest project document schema that can read this document", + "default": 3 + }, + "max_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Max Document Schema Version", + "description": "Newest project document schema understood by this client", + "default": 3 + }, + "workspace": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Workspace", + "description": "The caller's first workspace in the project, stored with the content in one transaction, so that no project is created without its creator's workspace. The workspace document itself: a create's workspace is always the first row, so it carries no revision. Checked as `PUT /projects/{id}/workspace` checks it, except that `max_canvas_schema_version` covers it. Needs `minimum_document_schema_version` at 4 or above, where a project is content and workspaces; beside a whole document it is refused with 400 (workspace_needs_split_document). Omit it to create the project without one." } }, "type": "object", @@ -92625,7 +92753,7 @@ "title": "ProjectCreateRequest", "description": "Request body for creating a project." }, - "ProjectRecordDTO": { + "ProjectSummaryDTO": { "properties": { "project_id": { "type": "string", @@ -92653,6 +92781,12 @@ "title": "Minimum Canvas Schema Version", "description": "Oldest canvas schema a client must support before it may read or write this project" }, + "minimum_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Minimum Document Schema Version", + "description": "Oldest project document schema a client must support before it may read or write this project" + }, "created_at": { "type": "string", "title": "Created At", @@ -92661,13 +92795,19 @@ "updated_at": { "type": "string", "title": "Updated At", - "description": "When the project was last saved" + "description": "When the project content was last saved" }, - "data": { - "additionalProperties": true, - "type": "object", - "title": "Data", - "description": "The opaque client-owned project document" + "workspace_updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workspace Updated At", + "description": "When the caller last saved their workspace in the project; null if they have none" } }, "type": "object", @@ -92677,14 +92817,115 @@ "name", "revision", "minimum_canvas_schema_version", + "minimum_document_schema_version", "created_at", - "updated_at", - "data" + "updated_at" ], - "title": "ProjectRecordDTO", - "description": "Full project record including the client-owned document." + "title": "ProjectSummaryDTO", + "description": "Lightweight project listing entry; the document payload is omitted." }, - "ProjectSummaryDTO": { + "ProjectUpdateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project document" + }, + "expected_revision": { + "type": "integer", + "title": "Expected Revision", + "description": "The revision this save is based on; mismatch returns 409" + }, + "minimum_canvas_schema_version": { + "anyOf": [ + { + "type": "integer", + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Minimum Canvas Schema Version", + "description": "New compatibility floor to store atomically with this document; omitted to keep the current floor" + }, + "max_canvas_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Max Canvas Schema Version", + "description": "Newest canvas schema understood by this client", + "default": 2 + }, + "minimum_document_schema_version": { + "anyOf": [ + { + "type": "integer", + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Minimum Document Schema Version", + "description": "New document schema floor to store atomically with this content; omitted to keep the current floor" + }, + "max_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Max Document Schema Version", + "description": "Newest project document schema understood by this client", + "default": 3 + }, + "workspace": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectUpdateWorkspaceRequest" + }, + { + "type": "null" + } + ], + "description": "The caller's workspace, to store in the same transaction as the content. For the save that splits a whole project (document schema 3 or older) into content and workspace while raising the document floor: the floor, the content and the first workspace row then land together, never by half. Its revision is checked as on the workspace route, and a mismatch refuses the whole save with 409. The save must leave the document floor at 4 or above, or it is refused whole with 400 (workspace_needs_split_document). Omit it on every other save; the workspace saves apart, on its own route." + } + }, + "type": "object", + "required": ["name", "data", "expected_revision"], + "title": "ProjectUpdateRequest", + "description": "Request body for saving a project with optimistic concurrency." + }, + "ProjectUpdateWorkspaceRequest": { + "properties": { + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned workspace document" + }, + "expected_revision": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expected Revision", + "description": "The workspace revision this save is based on, or null when the caller has no workspace in the project yet; a mismatch refuses the whole save, content included" + } + }, + "type": "object", + "required": ["data", "expected_revision"], + "title": "ProjectUpdateWorkspaceRequest", + "description": "The caller's workspace, saved in the same transaction as the project content it accompanies.\n\nOnly one client needs it: one that splits a whole project (document schema 3 or older) into content and\nworkspace. The raised floor, the content and the member's first workspace row then land together or not at all.\nThe save must leave the document floor at the split schema or above, or it is refused whole.\nLanded by half -- the content stored, the workspace not -- the project would join every later load with a\ndefault workspace, and the member's layout, settings, history and staging would be gone for good." + }, + "ProjectWithWorkspaceDTO": { "properties": { "project_id": { "type": "string", @@ -92712,6 +92953,12 @@ "title": "Minimum Canvas Schema Version", "description": "Oldest canvas schema a client must support before it may read or write this project" }, + "minimum_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Minimum Document Schema Version", + "description": "Oldest project document schema a client must support before it may read or write this project" + }, "created_at": { "type": "string", "title": "Created At", @@ -92720,7 +92967,36 @@ "updated_at": { "type": "string", "title": "Updated At", - "description": "When the project was last saved" + "description": "When the project content was last saved" + }, + "workspace_updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workspace Updated At", + "description": "When the caller last saved their workspace in the project; null if they have none" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project content (document schema 4 on)" + }, + "workspace": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectWorkspaceDTO" + }, + { + "type": "null" + } + ], + "description": "The caller's workspace. On a read, null until they save one, when the client builds a default; on a save, the workspace the save carried and stored, or null when it carried none" } }, "type": "object", @@ -92730,55 +93006,77 @@ "name", "revision", "minimum_canvas_schema_version", + "minimum_document_schema_version", "created_at", - "updated_at" + "updated_at", + "data", + "workspace" ], - "title": "ProjectSummaryDTO", - "description": "Lightweight project listing entry; the document payload is omitted." + "title": "ProjectWithWorkspaceDTO", + "description": "A project's content together with the caller's workspace in it." }, - "ProjectUpdateRequest": { + "ProjectWorkspaceDTO": { "properties": { - "name": { - "type": "string", - "title": "Name", - "description": "The project's display name" - }, "data": { "additionalProperties": true, "type": "object", "title": "Data", - "description": "The opaque client-owned project document" + "description": "The opaque client-owned workspace document" }, - "expected_revision": { + "revision": { "type": "integer", - "title": "Expected Revision", - "description": "The revision this save is based on; mismatch returns 409" + "title": "Revision", + "description": "Monotonic revision, incremented on every save of this workspace" }, - "minimum_canvas_schema_version": { + "updated_at": { + "type": "string", + "title": "Updated At", + "description": "When the workspace was last saved" + } + }, + "type": "object", + "required": ["data", "revision", "updated_at"], + "title": "ProjectWorkspaceDTO", + "description": "One account's workspace in a project: how they work on it (layout, widgets, generation settings)." + }, + "ProjectWorkspaceSaveRequest": { + "properties": { + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned workspace document" + }, + "expected_revision": { "anyOf": [ { - "type": "integer", - "minimum": 1.0 + "type": "integer" }, { "type": "null" } ], - "title": "Minimum Canvas Schema Version", - "description": "New compatibility floor to store atomically with this document; omitted to keep the current floor" + "title": "Expected Revision", + "description": "The workspace revision this save is based on, or null for the caller's first workspace in the project; a mismatch returns 409" + }, + "max_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Max Document Schema Version", + "description": "Newest project document schema understood by this client" }, "max_canvas_schema_version": { "type": "integer", "minimum": 1.0, "title": "Max Canvas Schema Version", - "description": "Newest canvas schema understood by this client", + "description": "Newest canvas schema understood by this client; the workspace holds canvas state", "default": 2 } }, "type": "object", - "required": ["name", "data", "expected_revision"], - "title": "ProjectUpdateRequest", - "description": "Request body for saving a project with optimistic concurrency." + "required": ["data", "expected_revision", "max_document_schema_version"], + "title": "ProjectWorkspaceSaveRequest", + "description": "Request body for saving the caller's workspace in a project with optimistic concurrency." }, "PromptEnhancerField": { "description": "Field for the optional ERNIE-Image prompt-enhancer (Ministral3ForCausalLM).", @@ -112780,39 +113078,6 @@ "description": "The board the media was added to, if any", "title": "Board Id" }, - "board_owner_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The owner of that board, who may differ from the uploader; None for no board", - "title": "Board Owner Id" - }, - "board_visibility": { - "anyOf": [ - { - "$ref": "#/components/schemas/BoardVisibility" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The visibility of that board; None when the media landed on no board" - }, - "shared_user_ids": { - "description": "Users a private board is explicitly shared with, who can see this media too", - "items": { - "type": "string" - }, - "title": "Shared User Ids", - "type": "array" - }, "video_name": { "description": "The name of the uploaded video", "title": "Video Name", @@ -112823,16 +113088,7 @@ "description": "The category of the uploaded video" } }, - "required": [ - "timestamp", - "user_id", - "board_id", - "board_owner_id", - "board_visibility", - "shared_user_ids", - "video_name", - "video_category" - ], + "required": ["timestamp", "user_id", "board_id", "video_name", "video_category"], "title": "VideoUploadedEvent", "type": "object" }, diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index e1eb1329801..e78cd2786a2 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -2463,20 +2463,19 @@ export type paths = { post?: never; /** * Delete Board - * @description Deletes a board (user must have access to it) + * @description Deletes a board: its owner's, or an admin's, to do */ delete: operations["delete_board"]; options?: never; head?: never; /** * Update Board - * @description Updates a board (user must have access to it). + * @description Updates a board. Each field takes its own board action: renaming, setting the cover and archiving need an + * editor; general access and moving between the Library and a project need the owner. * - * A project's inbox takes its name, archived state and visibility from the project and cannot be - * moved, so those changes are refused for it — for admins too; its cover is still fair game. A - * board in a project is private and unshared, so a move into a project, or a visibility change on - * a member, is refused when the result would be otherwise. The storage decides all of this in one - * transaction; the DTO read here is only for the ownership check. + * A project's inbox takes its name, archived state and general access from the project and cannot be moved, + * so those changes are refused for it — for admins too; its cover is still fair game. The storage decides + * that in one transaction. */ patch: operations["update_board"]; trace?: never; @@ -3066,7 +3065,8 @@ export type paths = { * returned, and it is not a total (counting every match would read the whole queue history); use the queue * status for totals. * - * IDs for every user's items are returned (item ids carry no sensitive data on their own). + * Without `origin_prefix`, IDs for every user's items are returned (item ids carry no sensitive data on their + * own); with it, a non-admin gets only their own. * When the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging * to other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see * partially-redacted entries for other users' jobs in the queue list, while still revealing @@ -3400,10 +3400,10 @@ export type paths = { }; /** * Get Queue Status - * @description Gets the status of the session queue. Returns global counts; every user additionally gets - * their own pending/in_progress counts (so the UI can show an X/Y badge and scope personal UI - * like the progress bar to the user's own activity). Non-admin users cannot see the current - * item's identifiers unless they own it. + * @description Gets the status of the session queue. Returns global counts (a non-admin's counts are their own when + * `origin_prefix` is given); every user additionally gets their own pending/in_progress counts (so the UI + * can show an X/Y badge and scope personal UI like the progress bar to the user's own activity). Non-admin + * users cannot see the current item's identifiers unless they own it. */ get: operations["get_queue_status"]; put?: never; @@ -4064,7 +4064,9 @@ export type paths = { put?: never; /** * Create Project - * @description Creates a project, and the private inbox board it owns, for the current user. + * @description Creates a project, and the private inbox board it owns, for the current user, and with it, when the request + * carries `workspace`, the caller's workspace in it, in one transaction. Returns the record with the workspace + * this create stored (null when it carried none). */ post: operations["create_project"]; delete?: never; @@ -4082,12 +4084,14 @@ export type paths = { }; /** * Get Project - * @description Gets one of the current user's projects, including its document. + * @description Gets one of the current user's projects: its content and the caller's workspace in it. */ get: operations["get_project"]; /** * Update Project - * @description Saves a project. Returns 409 with the current revision when the save is based on a stale revision. + * @description Saves a project's content and, when the request carries `workspace`, the caller's workspace in it, in one + * transaction. Returns the record with the workspace this save stored (null when it carried none). Returns 409 + * with the current revision when the save is based on a stale revision of either. */ put: operations["update_project"]; post?: never; @@ -4107,6 +4111,29 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/projects/{project_id}/workspace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Save Project Workspace + * @description Saves the caller's workspace in one of their projects: their layout, widgets and generation settings, apart + * from the project content, so that saving one never conflicts with the other. Returns 409 with the current + * revision (null when there is none) when the save is based on another, and 400 `workspace_needs_split_document` + * while the project is still held whole: the workspace is saved only beside content at the split schema. + */ + put: operations["save_project_workspace"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/projects/{project_id}/board-snapshot": { parameters: { query?: never; @@ -6196,6 +6223,12 @@ export type components = { */ type: "lblend"; }; + /** + * BoardAction + * @description What a caller can do to a board as a whole. + * @enum {string} + */ + BoardAction: "read" | "add_media" | "manage_items" | "rename" | "set_cover" | "archive" | "set_general_access" | "move" | "delete"; /** BoardChanges */ BoardChanges: { /** @@ -6213,7 +6246,12 @@ export type components = { * @description Whether or not the board is archived */ archived?: boolean | null; - /** @description The visibility of the board. */ + /** @description The role every other account gets on the board. Only its owner may change it. */ + general_access?: components["schemas"]["GeneralAccess"] | null; + /** + * @deprecated + * @description Deprecated, to be removed in 8.0: general access under its old name. Send `general_access`. + */ board_visibility?: components["schemas"]["BoardVisibility"] | null; /** * Project Id @@ -6267,10 +6305,10 @@ export type components = { */ archived: boolean; /** - * @description The visibility of the board. - * @default private + * @description The role every other account gets on the board: none (restricted), viewer or contributor. + * @default restricted */ - board_visibility?: components["schemas"]["BoardVisibility"]; + general_access?: components["schemas"]["GeneralAccess"]; /** * Project Id * @description The id of the owner's project this board belongs to; absent for a Library board. @@ -6305,9 +6343,28 @@ export type components = { asset_video_count?: number; /** * Owner Username - * @description The username of the board owner (for admin view). + * @description The board owner's display name. Admins get the email of an owner without one; other callers get 'Unknown user'. Null when the owner's account no longer exists. */ owner_username?: string | null; + /** + * @deprecated + * @description Deprecated, to be removed in 8.0: `general_access` under its old name. + * @default private + */ + board_visibility?: components["schemas"]["BoardVisibility"]; + /** @description The caller's own role on the board; null when only admin rights give access. */ + my_role?: components["schemas"]["BoardRole"] | null; + /** + * Allowed Actions + * @description The board actions the caller may take. Gate interfaces on these. + */ + allowed_actions?: components["schemas"]["BoardAction"][]; + /** + * Admin Override + * @description Whether admin rights give the caller more than `my_role` does. + * @default false + */ + admin_override?: boolean; /** * Is Inbox * @description Whether this board is its project's inbox, which only the project APIs may change. @@ -6332,9 +6389,15 @@ export type components = { * @enum {string} */ BoardRecordOrderBy: "created_at" | "board_name"; + /** + * BoardRole + * @description A caller's role on a board. Each role allows everything the roles before it allow. + * @enum {string} + */ + BoardRole: "viewer" | "contributor" | "editor" | "owner"; /** * BoardVisibility - * @description The visibility options for a board. + * @description Deprecated: a board's general access under its pre-7.0 name. Kept until 8.0 for older clients. * @enum {string} */ BoardVisibility: "private" | "shared" | "public"; @@ -17216,6 +17279,12 @@ export type components = { */ cpu_only: boolean | null; }; + /** + * GeneralAccess + * @description The role every other account gets on a board: none, viewer or contributor. + * @enum {string} + */ + GeneralAccess: "restricted" | "viewer" | "contributor"; /** * GeneratePasswordResponse * @description Response containing a generated password. @@ -20901,22 +20970,6 @@ export type components = { * @default null */ board_id: string | null; - /** - * Board Owner Id - * @description The owner of that board, who may differ from the uploader; None for no board - * @default null - */ - board_owner_id: string | null; - /** - * @description The visibility of that board; None when the media landed on no board - * @default null - */ - board_visibility: components["schemas"]["BoardVisibility"] | null; - /** - * Shared User Ids - * @description Users a private board is explicitly shared with, who can see this media too - */ - shared_user_ids: string[]; /** * Image Name * @description The name of the uploaded image @@ -21561,10 +21614,10 @@ export type components = { IntermediatesAffectedDocument: { /** * Kind - * @description `client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair + * @description `project_workspace` is one account's workspace in a project; `client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair * @enum {string} */ - kind: "project" | "workflow" | "client_state" | "quarantined_project"; + kind: "project" | "project_workspace" | "workflow" | "client_state" | "quarantined_project"; /** User Id */ user_id: string; /** @@ -39462,12 +39515,31 @@ export type components = { * @default 2 */ max_canvas_schema_version?: number; + /** + * Minimum Document Schema Version + * @description Oldest project document schema that can read this document + * @default 3 + */ + minimum_document_schema_version?: number; + /** + * Max Document Schema Version + * @description Newest project document schema understood by this client + * @default 3 + */ + max_document_schema_version?: number; + /** + * Workspace + * @description The caller's first workspace in the project, stored with the content in one transaction, so that no project is created without its creator's workspace. The workspace document itself: a create's workspace is always the first row, so it carries no revision. Checked as `PUT /projects/{id}/workspace` checks it, except that `max_canvas_schema_version` covers it. Needs `minimum_document_schema_version` at 4 or above, where a project is content and workspaces; beside a whole document it is refused with 400 (workspace_needs_split_document). Omit it to create the project without one. + */ + workspace?: { + [key: string]: unknown; + } | null; }; /** - * ProjectRecordDTO - * @description Full project record including the client-owned document. + * ProjectSummaryDTO + * @description Lightweight project listing entry; the document payload is omitted. */ - ProjectRecordDTO: { + ProjectSummaryDTO: { /** * Project Id * @description The project's client-generated identifier @@ -39493,6 +39565,11 @@ export type components = { * @description Oldest canvas schema a client must support before it may read or write this project */ minimum_canvas_schema_version: number; + /** + * Minimum Document Schema Version + * @description Oldest project document schema a client must support before it may read or write this project + */ + minimum_document_schema_version: number; /** * Created At * @description When the project was created @@ -39500,9 +39577,25 @@ export type components = { created_at: string; /** * Updated At - * @description When the project was last saved + * @description When the project content was last saved */ updated_at: string; + /** + * Workspace Updated At + * @description When the caller last saved their workspace in the project; null if they have none + */ + workspace_updated_at?: string | null; + }; + /** + * ProjectUpdateRequest + * @description Request body for saving a project with optimistic concurrency. + */ + ProjectUpdateRequest: { + /** + * Name + * @description The project's display name + */ + name: string; /** * Data * @description The opaque client-owned project document @@ -39510,12 +39603,65 @@ export type components = { data: { [key: string]: unknown; }; + /** + * Expected Revision + * @description The revision this save is based on; mismatch returns 409 + */ + expected_revision: number; + /** + * Minimum Canvas Schema Version + * @description New compatibility floor to store atomically with this document; omitted to keep the current floor + */ + minimum_canvas_schema_version?: number | null; + /** + * Max Canvas Schema Version + * @description Newest canvas schema understood by this client + * @default 2 + */ + max_canvas_schema_version?: number; + /** + * Minimum Document Schema Version + * @description New document schema floor to store atomically with this content; omitted to keep the current floor + */ + minimum_document_schema_version?: number | null; + /** + * Max Document Schema Version + * @description Newest project document schema understood by this client + * @default 3 + */ + max_document_schema_version?: number; + /** @description The caller's workspace, to store in the same transaction as the content. For the save that splits a whole project (document schema 3 or older) into content and workspace while raising the document floor: the floor, the content and the first workspace row then land together, never by half. Its revision is checked as on the workspace route, and a mismatch refuses the whole save with 409. The save must leave the document floor at 4 or above, or it is refused whole with 400 (workspace_needs_split_document). Omit it on every other save; the workspace saves apart, on its own route. */ + workspace?: components["schemas"]["ProjectUpdateWorkspaceRequest"] | null; }; /** - * ProjectSummaryDTO - * @description Lightweight project listing entry; the document payload is omitted. + * ProjectUpdateWorkspaceRequest + * @description The caller's workspace, saved in the same transaction as the project content it accompanies. + * + * Only one client needs it: one that splits a whole project (document schema 3 or older) into content and + * workspace. The raised floor, the content and the member's first workspace row then land together or not at all. + * The save must leave the document floor at the split schema or above, or it is refused whole. + * Landed by half -- the content stored, the workspace not -- the project would join every later load with a + * default workspace, and the member's layout, settings, history and staging would be gone for good. */ - ProjectSummaryDTO: { + ProjectUpdateWorkspaceRequest: { + /** + * Data + * @description The opaque client-owned workspace document + */ + data: { + [key: string]: unknown; + }; + /** + * Expected Revision + * @description The workspace revision this save is based on, or null when the caller has no workspace in the project yet; a mismatch refuses the whole save, content included + */ + expected_revision: number | null; + }; + /** + * ProjectWithWorkspaceDTO + * @description A project's content together with the caller's workspace in it. + */ + ProjectWithWorkspaceDTO: { /** * Project Id * @description The project's client-generated identifier @@ -39541,6 +39687,11 @@ export type components = { * @description Oldest canvas schema a client must support before it may read or write this project */ minimum_canvas_schema_version: number; + /** + * Minimum Document Schema Version + * @description Oldest project document schema a client must support before it may read or write this project + */ + minimum_document_schema_version: number; /** * Created At * @description When the project was created @@ -39548,40 +39699,72 @@ export type components = { created_at: string; /** * Updated At - * @description When the project was last saved + * @description When the project content was last saved */ updated_at: string; + /** + * Workspace Updated At + * @description When the caller last saved their workspace in the project; null if they have none + */ + workspace_updated_at?: string | null; + /** + * Data + * @description The opaque client-owned project content (document schema 4 on) + */ + data: { + [key: string]: unknown; + }; + /** @description The caller's workspace. On a read, null until they save one, when the client builds a default; on a save, the workspace the save carried and stored, or null when it carried none */ + workspace: components["schemas"]["ProjectWorkspaceDTO"] | null; }; /** - * ProjectUpdateRequest - * @description Request body for saving a project with optimistic concurrency. + * ProjectWorkspaceDTO + * @description One account's workspace in a project: how they work on it (layout, widgets, generation settings). */ - ProjectUpdateRequest: { + ProjectWorkspaceDTO: { /** - * Name - * @description The project's display name + * Data + * @description The opaque client-owned workspace document */ - name: string; + data: { + [key: string]: unknown; + }; + /** + * Revision + * @description Monotonic revision, incremented on every save of this workspace + */ + revision: number; + /** + * Updated At + * @description When the workspace was last saved + */ + updated_at: string; + }; + /** + * ProjectWorkspaceSaveRequest + * @description Request body for saving the caller's workspace in a project with optimistic concurrency. + */ + ProjectWorkspaceSaveRequest: { /** * Data - * @description The opaque client-owned project document + * @description The opaque client-owned workspace document */ data: { [key: string]: unknown; }; /** * Expected Revision - * @description The revision this save is based on; mismatch returns 409 + * @description The workspace revision this save is based on, or null for the caller's first workspace in the project; a mismatch returns 409 */ - expected_revision: number; + expected_revision: number | null; /** - * Minimum Canvas Schema Version - * @description New compatibility floor to store atomically with this document; omitted to keep the current floor + * Max Document Schema Version + * @description Newest project document schema understood by this client */ - minimum_canvas_schema_version?: number | null; + max_document_schema_version: number; /** * Max Canvas Schema Version - * @description Newest canvas schema understood by this client + * @description Newest canvas schema understood by this client; the workspace holds canvas state * @default 2 */ max_canvas_schema_version?: number; @@ -50377,22 +50560,6 @@ export type components = { * @default null */ board_id: string | null; - /** - * Board Owner Id - * @description The owner of that board, who may differ from the uploader; None for no board - * @default null - */ - board_owner_id: string | null; - /** - * @description The visibility of that board; None when the media landed on no board - * @default null - */ - board_visibility: components["schemas"]["BoardVisibility"] | null; - /** - * Shared User Ids - * @description Users a private board is explicitly shared with, who can see this media too - */ - shared_user_ids: string[]; /** * Video Name * @description The name of the uploaded video @@ -59442,7 +59609,7 @@ export interface operations { query?: { /** @description The order of sort */ order_dir?: components["schemas"]["SQLiteDirection"]; - /** @description Only include queue items whose origin starts with this prefix */ + /** @description Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match */ origin_prefix?: string | null; /** @description Return at most this many ids, from the start of the requested order. Omit to return every matching id */ limit?: number | null; @@ -59888,7 +60055,7 @@ export interface operations { get_current_queue_item: { parameters: { query?: { - /** @description Only include queue items whose origin starts with this prefix */ + /** @description Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match */ origin_prefix?: string | null; }; header?: never; @@ -59955,7 +60122,7 @@ export interface operations { get_next_queue_item: { parameters: { query?: { - /** @description Only include queue items whose origin starts with this prefix */ + /** @description Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match */ origin_prefix?: string | null; }; header?: never; @@ -59990,7 +60157,7 @@ export interface operations { get_queue_status: { parameters: { query?: { - /** @description Only include queue items whose origin starts with this prefix */ + /** @description Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match */ origin_prefix?: string | null; }; header?: never; @@ -61568,7 +61735,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectRecordDTO"]; + "application/json": components["schemas"]["ProjectWithWorkspaceDTO"]; }; }; /** @description Validation Error */ @@ -61587,6 +61754,8 @@ export interface operations { query?: { /** @description Newest canvas schema understood by this client */ max_canvas_schema_version?: number; + /** @description Newest project document schema understood by this client */ + max_document_schema_version?: number; }; header?: never; path: { @@ -61603,7 +61772,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectRecordDTO"]; + "application/json": components["schemas"]["ProjectWithWorkspaceDTO"]; }; }; /** @description Validation Error */ @@ -61639,7 +61808,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectRecordDTO"]; + "application/json": components["schemas"]["ProjectWithWorkspaceDTO"]; }; }; /** @description Validation Error */ @@ -61686,6 +61855,42 @@ export interface operations { }; }; }; + save_project_workspace: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the project whose workspace to save */ + project_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectWorkspaceSaveRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectWorkspaceDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_project_board_snapshot: { parameters: { query?: never; diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 690e6cbe8d7..3b39a114401 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": 797882, + "brotliBytes": 800593, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 796641, + "gzipBytes": 799703, "imageRawBytes": 44398, - "initialRawBytes": 2051780, - "largestAssetRawBytes": 752421, + "initialRawBytes": 2061277, + "largestAssetRawBytes": 752413, "otherAssetRawBytes": 0, - "ownedRawBytes": 98844, + "ownedRawBytes": 99011, "requestCount": 35, "scriptRequestCount": 19, "sourceOwners": [ @@ -329,6 +329,7 @@ "source:src/workbench/projects/projectFileErrors.ts", "source:src/workbench/projects/projectFileToasts.ts", "source:src/workbench/projects/projectFlush.ts", + "source:src/workbench/projects/projectHalves.ts", "source:src/workbench/projects/session.ts", "source:src/workbench/projects/syncStore.ts", "source:src/workbench/projects/useProjectFileActions.ts", @@ -348,17 +349,17 @@ }, "editor": { "baseline": { - "brotliBytes": 1189581, + "brotliBytes": 1197162, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1182627, + "gzipBytes": 1190526, "imageRawBytes": 44398, - "initialRawBytes": 3266960, - "largestAssetRawBytes": 752421, + "initialRawBytes": 3293139, + "largestAssetRawBytes": 752413, "otherAssetRawBytes": 0, - "ownedRawBytes": 154080, - "requestCount": 72, - "scriptRequestCount": 56, + "ownedRawBytes": 155635, + "requestCount": 71, + "scriptRequestCount": 55, "sourceOwners": [ "package:@ark-ui/react", "package:@chakra-ui/react", @@ -502,6 +503,7 @@ "source:src/features/fonts/launchpad.tsx", "source:src/features/fonts/react.tsx", "source:src/features/fonts/runtime.ts", + "source:src/features/gallery/core/boardAccess.ts", "source:src/features/gallery/core/boardLabels.ts", "source:src/features/gallery/core/image.ts", "source:src/features/gallery/core/imagePaths.ts", @@ -958,6 +960,7 @@ "source:src/workbench/projects/projectFileErrors.ts", "source:src/workbench/projects/projectFileToasts.ts", "source:src/workbench/projects/projectFlush.ts", + "source:src/workbench/projects/projectHalves.ts", "source:src/workbench/projects/projectHydration.ts", "source:src/workbench/projects/projectLifecycleLocks.ts", "source:src/workbench/projects/projectLoadRefusal.ts", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 5ee194d3e94..4c1b36ed337 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-17", + "browserExecutable": "/home/lstein/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome", + "capturedAt": "2026-10-18", "routes": [ { "activatedResourceBaseline": { @@ -14,13 +14,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 128, + "domContentLoadedMedianMs": 97.2000002861023, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 128.09999999403954, - "longestTaskMaxMs": 82, + "loadMedianMs": 97.40000009536743, + "longestTaskMaxMs": 64, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -29,14 +29,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 30, - "scriptRawBytes": 1852857, - "scriptRequestCount": 26, - "totalRawBytes": 2090003 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 29, + "scriptRawBytes": 1868300, + "scriptRequestCount": 25, + "totalRawBytes": 2114972 }, - "routeReadyMedianMs": 266.79999999701977, + "routeReadyMedianMs": 215.30000019073486, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -52,13 +52,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.70000000298023, + "domContentLoadedMedianMs": 101, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.79999999701977, - "longestTaskMaxMs": 79, + "loadMedianMs": 101.09999990463257, + "longestTaskMaxMs": 67, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -67,14 +67,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 30, - "scriptRawBytes": 1852857, - "scriptRequestCount": 26, - "totalRawBytes": 2090003 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 29, + "scriptRawBytes": 1868300, + "scriptRequestCount": 25, + "totalRawBytes": 2114972 }, - "routeReadyMedianMs": 278.09999999403954, + "routeReadyMedianMs": 225.39999961853027, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -90,29 +90,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 128.70000000298023, + "domContentLoadedMedianMs": 99.69999980926514, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 128.8999999910593, - "longestTaskMaxMs": 174, + "loadMedianMs": 99.7999997138977, + "longestTaskMaxMs": 148, "owner": "workbench", - "projectSwitchMedianMs": 35.20000000298023, + "projectSwitchMedianMs": 34.90000009536743, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 88, - "scriptRawBytes": 4131338, - "scriptRequestCount": 83, - "totalRawBytes": 4412882 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 87, + "scriptRawBytes": 4199867, + "scriptRequestCount": 82, + "totalRawBytes": 4490937 }, - "routeReadyMedianMs": 443.29999999701977, + "routeReadyMedianMs": 360.90000009536743, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -128,29 +128,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 127.69999998807907, + "domContentLoadedMedianMs": 100.5, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 127.8999999910593, - "longestTaskMaxMs": 166, + "loadMedianMs": 100.60000038146973, + "longestTaskMaxMs": 146, "owner": "workbench", - "projectSwitchMedianMs": 44, + "projectSwitchMedianMs": 48.40000009536743, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 88, - "scriptRawBytes": 4131338, - "scriptRequestCount": 83, - "totalRawBytes": 4412882 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 87, + "scriptRawBytes": 4199867, + "scriptRequestCount": 82, + "totalRawBytes": 4490937 }, - "routeReadyMedianMs": 442.79999999701977, + "routeReadyMedianMs": 367.80000019073486, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -159,20 +159,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305464, + "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 516145, + "scriptRawBytes": 518622, "scriptRequestCount": 9, - "totalRawBytes": 516145 + "totalRawBytes": 518622 }, - "domContentLoadedMedianMs": 134.30000001192093, + "domContentLoadedMedianMs": 100.40000009536743, "id": "editor-canvas", - "layoutAckMedianMs": 2.7000000029802322, - "layoutReturnSwitchMedianMs": 62.099999994039536, - "layoutSwitchMedianMs": 81.19999998807907, - "loadMedianMs": 134.5, - "longestTaskMaxMs": 179, + "layoutAckMedianMs": 2.8000001907348633, + "layoutReturnSwitchMedianMs": 51.39999961853027, + "layoutSwitchMedianMs": 70.09999990463257, + "loadMedianMs": 100.5, + "longestTaskMaxMs": 149, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -181,14 +181,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 97, - "scriptRawBytes": 4647483, - "scriptRequestCount": 92, - "totalRawBytes": 4929027 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 96, + "scriptRawBytes": 4718489, + "scriptRequestCount": 91, + "totalRawBytes": 5009559 }, - "routeReadyMedianMs": 1204.2999999970198, + "routeReadyMedianMs": 1095.3000001907349, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -197,20 +197,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305464, + "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 516145, + "scriptRawBytes": 518622, "scriptRequestCount": 9, - "totalRawBytes": 516145 + "totalRawBytes": 518622 }, - "domContentLoadedMedianMs": 130.59999999403954, + "domContentLoadedMedianMs": 97.7000002861023, "id": "editor-canvas", "layoutAckMedianMs": 3, - "layoutReturnSwitchMedianMs": 53.5, - "layoutSwitchMedianMs": 77.3999999910593, - "loadMedianMs": 130.70000000298023, - "longestTaskMaxMs": 197, + "layoutReturnSwitchMedianMs": 45.30000019073486, + "layoutSwitchMedianMs": 68.90000009536743, + "loadMedianMs": 97.90000009536743, + "longestTaskMaxMs": 148, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -219,14 +219,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 97, - "scriptRawBytes": 4647483, - "scriptRequestCount": 92, - "totalRawBytes": 4929027 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 96, + "scriptRawBytes": 4718489, + "scriptRequestCount": 91, + "totalRawBytes": 5009559 }, - "routeReadyMedianMs": 1190.8999999910593, + "routeReadyMedianMs": 1042.5, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -235,20 +235,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 201666, + "largestAssetRawBytes": 201529, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 443711, + "scriptRawBytes": 444643, "scriptRequestCount": 6, - "totalRawBytes": 459124 + "totalRawBytes": 460056 }, - "domContentLoadedMedianMs": 130.79999999701977, + "domContentLoadedMedianMs": 101.40000009536743, "id": "editor-workflow", - "layoutAckMedianMs": 3, - "layoutReturnSwitchMedianMs": 37.1000000089407, - "layoutSwitchMedianMs": 79.29999999701977, - "loadMedianMs": 130.8999999910593, - "longestTaskMaxMs": 176, + "layoutAckMedianMs": 2.9000000953674316, + "layoutReturnSwitchMedianMs": 32.700000286102295, + "layoutSwitchMedianMs": 73.09999990463257, + "loadMedianMs": 101.5, + "longestTaskMaxMs": 146, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -257,14 +257,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 95, - "scriptRawBytes": 4575049, - "scriptRequestCount": 89, - "totalRawBytes": 4872006 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 94, + "scriptRawBytes": 4644510, + "scriptRequestCount": 88, + "totalRawBytes": 4950993 }, - "routeReadyMedianMs": 1193.4000000059605, + "routeReadyMedianMs": 1093.0999999046326, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -273,20 +273,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 201666, + "largestAssetRawBytes": 201529, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 443711, + "scriptRawBytes": 444643, "scriptRequestCount": 6, - "totalRawBytes": 459124 + "totalRawBytes": 460056 }, - "domContentLoadedMedianMs": 131, + "domContentLoadedMedianMs": 99.89999961853027, "id": "editor-workflow", - "layoutAckMedianMs": 3, - "layoutReturnSwitchMedianMs": 46.3999999910593, - "layoutSwitchMedianMs": 156, - "loadMedianMs": 131.09999999403954, - "longestTaskMaxMs": 305, + "layoutAckMedianMs": 2.799999713897705, + "layoutReturnSwitchMedianMs": 41.09999990463257, + "layoutSwitchMedianMs": 125.60000038146973, + "loadMedianMs": 100, + "longestTaskMaxMs": 226, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -295,14 +295,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 95, - "scriptRawBytes": 4575049, - "scriptRequestCount": 89, - "totalRawBytes": 4872006 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 94, + "scriptRawBytes": 4644510, + "scriptRequestCount": 88, + "totalRawBytes": 4950993 }, - "routeReadyMedianMs": 1273.2999999970198, + "routeReadyMedianMs": 1105.0999999046326, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -318,13 +318,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 129.19999998807907, + "domContentLoadedMedianMs": 98.30000019073486, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 42.29999999701977, - "loadMedianMs": 129.29999999701977, - "longestTaskMaxMs": 175, + "layoutSwitchMedianMs": 43.59999990463257, + "loadMedianMs": 98.5, + "longestTaskMaxMs": 144, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -333,14 +333,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 88, - "scriptRawBytes": 4131338, - "scriptRequestCount": 83, - "totalRawBytes": 4412882 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 87, + "scriptRawBytes": 4199867, + "scriptRequestCount": 82, + "totalRawBytes": 4490937 }, - "routeReadyMedianMs": 1437.8999999910593, + "routeReadyMedianMs": 1324.5, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -356,13 +356,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 132.5, + "domContentLoadedMedianMs": 99.80000019073486, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 50.8999999910593, - "loadMedianMs": 132.69999998807907, - "longestTaskMaxMs": 181, + "layoutSwitchMedianMs": 50, + "loadMedianMs": 99.90000009536743, + "longestTaskMaxMs": 145, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -371,14 +371,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752421, - "otherRawBytes": 216879, - "requestCount": 88, - "scriptRawBytes": 4131338, - "scriptRequestCount": 83, - "totalRawBytes": 4412882 + "largestAssetRawBytes": 752413, + "otherRawBytes": 220849, + "requestCount": 87, + "scriptRawBytes": 4199867, + "scriptRequestCount": 82, + "totalRawBytes": 4490937 }, - "routeReadyMedianMs": 1443.8999999910593, + "routeReadyMedianMs": 1301.5, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -512,6 +512,7 @@ "source:src/features/fonts/launchpad.tsx", "source:src/features/fonts/react.tsx", "source:src/features/fonts/runtime.ts", + "source:src/features/gallery/core/boardAccess.ts", "source:src/features/gallery/core/imagePaths.ts", "source:src/features/gallery/core/items.ts", "source:src/features/gallery/core/recentImages.ts", @@ -523,6 +524,7 @@ "source:src/features/gallery/data/imageUrls.ts", "source:src/features/gallery/data/videoUrls.ts", "source:src/features/gallery/launchpad.ts", + "source:src/features/gallery/react.ts", "source:src/features/gallery/ui/GalleryUiContext.tsx", "source:src/features/gallery/ui/galleryStateView.ts", "source:src/features/gallery/ui/launchpad/RecentOutputs.tsx", @@ -741,6 +743,7 @@ "source:src/workbench/projects/projectFileErrors.ts", "source:src/workbench/projects/projectFileToasts.ts", "source:src/workbench/projects/projectFlush.ts", + "source:src/workbench/projects/projectHalves.ts", "source:src/workbench/projects/session.ts", "source:src/workbench/projects/syncStore.ts", "source:src/workbench/projects/useProjectFileActions.ts", @@ -897,6 +900,7 @@ "source:src/features/fonts/launchpad.tsx", "source:src/features/fonts/react.tsx", "source:src/features/fonts/runtime.ts", + "source:src/features/gallery/core/boardAccess.ts", "source:src/features/gallery/core/boardLabels.ts", "source:src/features/gallery/core/image.ts", "source:src/features/gallery/core/imagePaths.ts", @@ -1665,6 +1669,7 @@ "source:src/workbench/projects/projectFileErrors.ts", "source:src/workbench/projects/projectFileToasts.ts", "source:src/workbench/projects/projectFlush.ts", + "source:src/workbench/projects/projectHalves.ts", "source:src/workbench/projects/projectHydration.ts", "source:src/workbench/projects/projectLifecycleLocks.ts", "source:src/workbench/projects/projectLoadRefusal.ts", @@ -1986,6 +1991,7 @@ "source:src/features/fonts/launchpad.tsx", "source:src/features/fonts/react.tsx", "source:src/features/fonts/runtime.ts", + "source:src/features/gallery/core/boardAccess.ts", "source:src/features/gallery/core/boardLabels.ts", "source:src/features/gallery/core/image.ts", "source:src/features/gallery/core/imagePaths.ts", @@ -2772,6 +2778,7 @@ "source:src/workbench/projects/projectFileErrors.ts", "source:src/workbench/projects/projectFileToasts.ts", "source:src/workbench/projects/projectFlush.ts", + "source:src/workbench/projects/projectHalves.ts", "source:src/workbench/projects/projectHydration.ts", "source:src/workbench/projects/projectLifecycleLocks.ts", "source:src/workbench/projects/projectLoadRefusal.ts", @@ -3247,6 +3254,7 @@ "source:src/features/fonts/launchpad.tsx", "source:src/features/fonts/react.tsx", "source:src/features/fonts/runtime.ts", + "source:src/features/gallery/core/boardAccess.ts", "source:src/features/gallery/core/boardLabels.ts", "source:src/features/gallery/core/image.ts", "source:src/features/gallery/core/imagePaths.ts", @@ -4091,6 +4099,7 @@ "source:src/workbench/projects/projectFileErrors.ts", "source:src/workbench/projects/projectFileToasts.ts", "source:src/workbench/projects/projectFlush.ts", + "source:src/workbench/projects/projectHalves.ts", "source:src/workbench/projects/projectHydration.ts", "source:src/workbench/projects/projectLifecycleLocks.ts", "source:src/workbench/projects/projectLoadRefusal.ts", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 4299aa3a043..e94c6a760df 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -506,7 +506,8 @@ "tooLargeToWrite": "This project is too large to package into a project file.", "unstarredItems_one": "1 item could not be re-starred.", "unstarredItems_other": "{{count}} items could not be re-starred.", - "unsupportedVersion": "This project file was written by a newer version of Invoke." + "unsupportedVersion": "This project file was written by a newer version of Invoke.", + "workspaceInseparable": "This version of Invoke can't read this project, so it can't leave your settings out of the file. Export it with your settings for this project included." }, "gridView": "Grid view", "groups": { @@ -558,6 +559,10 @@ }, "unpin": "Unpin", "viewLabel": "Project layout", + "exportOptions": { + "includeWorkspace": "Include my settings for this project", + "workspaceDescription": "Your layout, generation settings, prompt history and staged results for this project. Turn this off to share the project without them." + }, "fonts": { "exportTitle": "Export {{name}}", "includeFiles": "Include font files", @@ -2237,6 +2242,9 @@ "export": "Export local draft", "revisionDescription": "The server and this tab contain different edits. You can keep both versions or replace this tab with the server version.", "revisionTitle": "Project changed elsewhere", + "workspaceRevisionDescription": "Your layout, prompts and generation settings for this project were saved from another tab or device. You can keep both versions, or use the server's settings and keep this tab's edits to the project.", + "workspaceRevisionTitle": "Your settings for this project changed elsewhere", + "workspaceUseServerConfirmBody": "Replace this tab's layout, prompts and generation settings with the server version? Your edits to the project itself are kept.", "saveAsNew": "Save as new project", "saveAsNewConfirmBody": "Create a separate project from the local version? Editing is paused until the copy is safely created.", "schemaDescription": "This project needs a newer version of InvokeAI. Export it before discarding the local draft.", @@ -4291,6 +4299,7 @@ "archivedBoards": "Archived boards", "autoAddBadge": "Auto", "autoAddBadgeTooltip": "New generations, canvas saves and pasted uploads land here, as do workflow nodes whose board is Auto.", + "autoAddRedirectedTooltip": "You can only view {{name}}, so new generations, canvas saves and pasted uploads land here instead, as do workflow nodes whose board is Auto.", "autoAddToBoard": "Auto-add to this board", "stopAutoAdd": "Stop auto-adding here", "assetCount_one": "{{count}} upload", @@ -4304,7 +4313,39 @@ "boardGroups": { "byDate": "By Date", "library": "Library", - "otherProjects": "Other projects" + "otherProjects": "Other projects", + "otherUsers": "Other users' boards", + "shared": "Shared with me" + }, + "boardAccess": { + "admin": "Admin access", + "askToAdd": "Ask {{owner}} to let you add media.", + "changed": "Access set to {{access}}", + "contributor": "Can add", + "deletedOwner": "Deleted user", + "editor": "Can edit", + "general": { + "contributor": "Everyone can contribute", + "contributorDescription": "Everyone can view it and add media. They can remove only what they added.", + "label": "Who can open this board", + "restricted": "Restricted", + "restrictedDescription": "Only you, plus admins.", + "restrictedDescriptionOther": "Only {{owner}}, plus admins.", + "viewer": "Everyone can view", + "viewerDescription": "Everyone signed in can open it and download its media." + }, + "header": { + "admin": "{{owner}}'s board · admin access", + "contributor": "{{owner}}'s board · you can add to it", + "editor": "{{owner}}'s board · you can edit it", + "viewer": "{{owner}}'s board · you can view it" + }, + "gone": "This board no longer exists", + "lost": "You no longer have access to this board", + "menu": "Access", + "ownerAndAccess": "{{owner}} · {{access}}", + "stale": "Your access to this board changed", + "viewer": "Can view" }, "boardMoved": "Moved board \"{{name}}\" to {{destination}}", "changeBoard": "Change Board", @@ -4618,7 +4659,10 @@ "uploadSuccessTitle_one": "Uploaded {{count}} file", "uploadSuccessTitle_other": "Uploaded {{count}} files", "uploadSummary": "{{images}} and {{videos}} uploaded to {{board}}. {{failed}} failed.", + "uploadsUnavailableForBoard": "Uploads can't go to {{name}}: it is no longer available.", "uploadsUnavailableForDateBoards": "Uploads are unavailable for date boards", + "uploadsUnavailableForUnlistedBoard": "Uploads can't go to the selected board: it is no longer available.", + "uploadsUnavailableForViewOnlyBoard": "You can view {{name}} but not add to it", "uploadUnsupported": "No supported media files to upload (PNG, JPEG, or WebP images; video files; or audio files).", "videoCount_one": "{{count}} video", "videoCount_other": "{{count}} videos", @@ -5124,6 +5168,8 @@ "affected": "Documents that will lose references", "affectedProject_one": "Project “{{name}}” ({{count, number}} item)", "affectedProject_other": "Project “{{name}}” ({{count, number}} items)", + "affectedProjectWorkspace_one": "Workspace in project “{{name}}” ({{count, number}} item)", + "affectedProjectWorkspace_other": "Workspace in project “{{name}}” ({{count, number}} items)", "affectedWorkflow_one": "Workflow “{{name}}” ({{count, number}} item)", "affectedWorkflow_other": "Workflow “{{name}}” ({{count, number}} items)", "affectedOwner": "· {{owner}}", @@ -5229,6 +5275,10 @@ "projects": { "description": "Autosaving projects keep your canvas, settings, and workflows together, and export as .invk files.", "title": "Projects" + }, + "sharing": { + "description": "Choose who can open your boards from their Access menu. Boards others share with you appear under Shared with me.", + "title": "Share boards" } }, "items": [ @@ -5242,8 +5292,12 @@ "Multi-GPU support.", "Interactive intermediates manager.", "Full tablet support.", + "Your layout, generation settings and prompt history stay yours in each project; export asks whether to include them.", "And much more!" ], + "multiuserItems": [ + "Public boards are now Everyone can contribute: others can add media and remove only what they added." + ], "readReleaseNotes": "Read Release Notes", "readTheDocs": "Read the Docs", "whatsNewInInvoke": "What's New in Invoke" diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs index 2103c11d90e..1f0a5599b91 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs @@ -306,7 +306,7 @@ const buildBoard = (boardId, boardName, images, videos, createdAt, projectId = n asset_count: boardImages.filter((image) => image.image_category !== 'general').length, board_id: boardId, board_name: boardName, - board_visibility: 'private', + general_access: 'restricted', cover_image_name: cover?.kind === 'image' ? cover.name : null, cover_video_name: cover?.kind === 'video' ? cover.name : null, created_at: createdAt, diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index f7764bb6dcc..8f4d1fbcb50 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -904,6 +904,200 @@ test('copying media mints fresh identities and deleting a project keeps its medi }); }); +test('a project claims only an unshared Library board of the caller’s own', async () => { + await withRepresentativeBackend(async (backend) => { + const call = (path, init) => + fetch(`${backend.origin}${path}`, { + ...init, + headers: { 'content-type': 'application/json' }, + ...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + const claim = (boardId) => call('/api/v1/projects/', { body: { board_id: boardId, data: {} }, method: 'POST' }); + const listed = await getJson(backend, '/api/v1/boards/'); + const staging = listed.find((board) => board.project_id === null && !board.is_inbox); + + // Seeded boards carry their general access, and the deprecated visibility alias is derived from it. + assert.equal(staging.general_access, 'restricted'); + assert.equal(staging.board_visibility, 'private'); + + // Shared with everyone: not claimable, until restricted again. + await call('/__board-access', { + body: { board_id: staging.board_id, general_access: 'contributor' }, + method: 'POST', + }); + assert.equal((await getJson(backend, `/api/v1/boards/${staging.board_id}`)).board_visibility, 'public'); + assert.equal((await claim(staging.board_id)).status, 409); + await call('/__board-access', { + body: { board_id: staging.board_id, general_access: 'restricted' }, + method: 'POST', + }); + + // Shared with a member: not claimable either. + await call('/__board-access', { body: { board_id: staging.board_id, member_role: 'viewer' }, method: 'POST' }); + assert.equal((await claim(staging.board_id)).status, 409); + await call('/__board-access', { body: { board_id: staging.board_id, member_role: null }, method: 'POST' }); + + // Someone else's board, even one the caller may read, is never theirs to claim. + await call('/__faults', { body: { sharedBoards: true }, method: 'POST' }); + assert.equal((await claim('shared-contributor')).status, 409); + + const claimed = await claim(staging.board_id); + assert.equal(claimed.ok, true); + assert.equal((await claimed.json()).board_id, staging.board_id); + }); +}); + +test('a project save may carry the caller’s workspace, stored with the content or refused with it', async () => { + await withRepresentativeBackend(async (backend) => { + const call = (path, init) => + fetch(`${backend.origin}${path}`, { + ...init, + headers: { 'content-type': 'application/json' }, + ...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + const [project] = await getJson(backend, '/api/v1/projects/'); + const save = (body) => + call(`/api/v1/projects/${project.project_id}`, { + body: { max_document_schema_version: 4, ...body }, + method: 'PUT', + }); + + // Content alone: the answer names no workspace. + const contentOnly = await save({ data: project.data, expected_revision: project.revision }); + assert.equal(contentOnly.status, 200); + const contentOnlyRecord = await contentOnly.json(); + assert.equal(contentOnlyRecord.revision, project.revision + 1); + assert.equal(contentOnlyRecord.workspace, null); + + // A workspace beside a whole document, or the split without one, is refused as the server refuses it. + const beforeSplit = await save({ + data: project.data, + expected_revision: contentOnlyRecord.revision, + workspace: { data: { layout: 'early' }, expected_revision: null }, + }); + assert.equal(beforeSplit.status, 400); + assert.equal((await beforeSplit.json()).detail.code, 'workspace_needs_split_document'); + const contentOnlySplit = await save({ + data: project.data, + expected_revision: contentOnlyRecord.revision, + minimum_document_schema_version: 4, + }); + assert.equal(contentOnlySplit.status, 400); + assert.deepEqual((await contentOnlySplit.json()).detail.code, 'split_needs_workspace'); + assert.equal( + (await getJson(backend, `/api/v1/projects/${project.project_id}`)).revision, + contentOnlyRecord.revision + ); + + // Content and the first workspace, in one write. + const split = await save({ + data: project.data, + expected_revision: contentOnlyRecord.revision, + minimum_document_schema_version: 4, + workspace: { data: { layout: 'split' }, expected_revision: null }, + }); + assert.equal(split.status, 200); + const splitRecord = await split.json(); + assert.equal(splitRecord.revision, contentOnlyRecord.revision + 1); + assert.deepEqual(splitRecord.workspace.data, { layout: 'split' }); + assert.equal(splitRecord.workspace.revision, 1); + assert.equal(typeof splitRecord.workspace.updated_at, 'string'); + assert.equal(splitRecord.workspace_updated_at, splitRecord.workspace.updated_at); + + // A stale workspace revision refuses the whole save: the content revision does not move either. + const stale = await save({ + data: { ...project.data, changed: true }, + expected_revision: splitRecord.revision, + workspace: { data: { layout: 'stale' }, expected_revision: null }, + }); + assert.equal(stale.status, 409); + assert.deepEqual(await stale.json(), { + detail: { current_revision: 1, message: 'Workspace revision conflict' }, + }); + const unchanged = await getJson(backend, `/api/v1/projects/${project.project_id}?max_document_schema_version=4`); + assert.equal(unchanged.revision, splitRecord.revision); + assert.deepEqual(unchanged.workspace.data, { layout: 'split' }); + + // A stale content revision answers with the current one, as the server does. + const staleContent = await save({ data: project.data, expected_revision: 0 }); + assert.equal(staleContent.status, 409); + assert.deepEqual(await staleContent.json(), { + detail: { current_revision: splitRecord.revision, message: 'Revision conflict' }, + }); + + // The workspace route answers the canvas floor as the content routes do. + const belowFloor = await call(`/api/v1/projects/${project.project_id}/workspace`, { + body: { data: {}, expected_revision: 1, max_canvas_schema_version: 1, max_document_schema_version: 4 }, + method: 'PUT', + }); + assert.equal(belowFloor.status, 412); + const refusal = await belowFloor.json(); + assert.equal(refusal.detail.code, 'canvas_schema_unsupported'); + assert.equal(refusal.detail.max_canvas_schema_version, 1); + assert.equal(refusal.detail.minimum_canvas_schema_version, unchanged.minimum_canvas_schema_version); + const saved = await call(`/api/v1/projects/${project.project_id}/workspace`, { + body: { data: { layout: 'own-route' }, expected_revision: 1, max_document_schema_version: 4 }, + method: 'PUT', + }); + assert.equal(saved.status, 200); + assert.equal((await saved.json()).revision, 2); + }); +}); + +test('a project create may carry the caller’s first workspace, stored with the content', async () => { + await withRepresentativeBackend(async (backend) => { + const call = (path, init) => + fetch(`${backend.origin}${path}`, { + ...init, + headers: { 'content-type': 'application/json' }, + ...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + const create = (body) => + call('/api/v1/projects/', { body: { max_document_schema_version: 4, ...body }, method: 'POST' }); + + // Content alone: the answer names no workspace. + const contentOnly = await create({ data: { documentSchemaVersion: 4 }, minimum_document_schema_version: 4 }); + assert.equal(contentOnly.status, 200); + assert.equal((await contentOnly.json()).workspace, null); + + // Both halves in one request, answered with the workspace it stored, which a read then carries. + const created = await create({ + data: { documentSchemaVersion: 4 }, + minimum_document_schema_version: 4, + project_id: 'created-with-workspace', + workspace: { layout: 'first' }, + }); + assert.equal(created.status, 200); + const record = await created.json(); + assert.deepEqual(record.workspace.data, { layout: 'first' }); + assert.equal(record.workspace.revision, 1); + assert.equal(record.workspace_updated_at, record.workspace.updated_at); + const read = await getJson(backend, '/api/v1/projects/created-with-workspace?max_document_schema_version=4'); + assert.deepEqual(read.workspace, record.workspace); + + // A workspace beside a whole document is refused, as the server refuses it, and the refused create claims no + // board: the staging board is still the caller's to delete or claim. + const staging = await getJson(backend, '/api/v1/boards/?board_name=Staging', { method: 'POST' }); + const refused = await create({ + board_id: staging.board_id, + data: { documentSchemaVersion: 3 }, + minimum_document_schema_version: 3, + project_id: 'refused-whole', + workspace: { layout: 'early' }, + }); + assert.equal(refused.status, 400); + assert.deepEqual(await refused.json(), { + detail: { + code: 'workspace_needs_split_document', + message: 'A workspace is saved only with content at document schema 4 or newer.', + minimum_document_schema_version: 3, + }, + }); + assert.equal((await getJson(backend, `/api/v1/boards/${staging.board_id}`)).project_id, null); + assert.equal((await call('/api/v1/projects/refused-whole')).status, 404); + }); +}); + test('the image map serves videos only on request and keeps its two endpoints comparable', async () => { await withRepresentativeBackend(async (backend) => { const imagesOnly = await getJson(backend, '/api/v1/image_map/points'); @@ -988,6 +1182,74 @@ test('workflow thumbnails round-trip their bytes and refresh the listed URL on e }); }); +test('shared boards answer as the backend role table does', async () => { + const backend = await startMockBackend(0, { profile: 'representative' }); + const call = (path, init) => + fetch(`${backend.origin}${path}`, { + ...init, + headers: { 'content-type': 'application/json' }, + ...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + + try { + await call('/__faults', { body: { intermediatesCaller: 'user', sharedBoards: true }, method: 'POST' }); + const listed = await (await call('/api/v1/boards/')).json(); + const byId = new Map(listed.map((board) => [board.board_id, board])); + + // Membership, general access, and nothing for a restricted board of someone else's. + assert.deepEqual(byId.get('shared-editor').allowed_actions, [ + 'read', + 'add_media', + 'manage_items', + 'rename', + 'set_cover', + 'archive', + ]); + assert.deepEqual(byId.get('shared-viewer').allowed_actions, ['read']); + assert.equal(byId.get('shared-contributor').my_role, 'contributor'); + assert.equal(byId.has('others-private'), false); + // A board that exists but the caller may not read answers 403; only a missing board answers 404. + const unreadable = await call('/api/v1/boards/others-private'); + assert.equal(unreadable.status, 403); + assert.deepEqual(await unreadable.json(), { + detail: { code: 'board_unreadable', message: 'Not authorized to access this board' }, + }); + assert.equal((await call('/api/v1/boards/no-such-board')).status, 404); + assert.equal( + (await call('/api/v1/images/copy', { body: { board_id: 'others-private', image_names: [] }, method: 'POST' })) + .status, + 403 + ); + + // The role is refused before anything else; an inbox's own refusal comes after it. + const forbidden = await call('/api/v1/boards/shared-viewer', { body: { board_name: 'x' }, method: 'PATCH' }); + assert.equal(forbidden.status, 403); + assert.deepEqual(await forbidden.json(), { + detail: { code: 'board_action_denied', message: 'Not authorized to change this board' }, + }); + const inbox = listed.find((board) => board.is_inbox); + assert.equal( + (await call(`/api/v1/boards/${inbox.board_id}`, { body: { board_name: 'x' }, method: 'PATCH' })).status, + 409 + ); + + // A revocation behind the page's back makes the board unreadable: it still exists, so it answers 403. + await call('/__board-access', { body: { board_id: 'shared-editor', member_role: null }, method: 'POST' }); + assert.equal( + (await call('/api/v1/boards/shared-editor', { body: { board_name: 'x' }, method: 'PATCH' })).status, + 403 + ); + assert.equal((await call('/api/v1/boards/shared-editor')).status, 403); + const relisted = await (await call('/api/v1/boards/')).json(); + assert.equal( + relisted.some((board) => board.board_id === 'shared-editor'), + false + ); + } finally { + await backend.close(); + } +}); + 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'); diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 281b8c8c77f..7d101a6cbda 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -16,18 +16,44 @@ import { const DEFAULT_CANVAS_SCHEMA_VERSION = 3; /** The 412 the real router raises when a client's maximum is below a project's floor. */ -const schemaUnsupported = (minimum, maximum) => - json(412, { +const schemaUnsupported = (minimum, maximum) => [ + 412, + { detail: { code: 'canvas_schema_unsupported', max_canvas_schema_version: maximum, message: `Project requires canvas schema ${minimum}; client supports up to ${maximum}.`, minimum_canvas_schema_version: minimum, }, - }); + }, +]; const clientMaximum = (value) => (Number.isInteger(value) && value >= 1 ? value : DEFAULT_CANVAS_SCHEMA_VERSION); +/** A project saved whole; from 4 the record holds its content and each member's workspace apart. */ +const DEFAULT_DOCUMENT_SCHEMA_VERSION = 3; +const SPLIT_DOCUMENT_SCHEMA_VERSION = 4; + +const documentSchemaUnsupported = (minimum, maximum) => [ + 412, + { + detail: { + code: 'document_schema_unsupported', + max_document_schema_version: maximum, + message: `Project requires document schema ${minimum}; client supports up to ${maximum}.`, + minimum_document_schema_version: minimum, + }, + }, +]; + +const documentMaximum = (value) => (Number.isInteger(value) && value >= 1 ? value : DEFAULT_DOCUMENT_SCHEMA_VERSION); + +/** A write's answer: the content; the caller's workspace goes beside it only where the write stored one. */ +const writtenRecordOf = ({ workspace, ...project }) => ({ + ...project, + workspace_updated_at: workspace?.updated_at ?? null, +}); + /** In-memory test backend; no Socket.IO. POST /__reset restores the startup profile unless ?profile= overrides it. */ const FIXED_EPOCH_MS = Date.parse(MOCK_BACKEND_FIXED_EPOCH); @@ -46,15 +72,126 @@ const ARCHITECTURE_CAPABILITIES = JSON.parse( ); /** - * POST /__faults sets capabilities to error, empty, or ok, and `intermediatesCaller` to admin or user, until POST - * /__reset; independent of workload profile. `user` also turns on multi-user auth with a non-admin session: sign in - * with any email and password (or reload a page that already holds the token) to reach the non-admin UI. + * POST /__faults sets capabilities to error, empty, or ok, and `intermediatesCaller` to admin, user or + * multiuser-admin, until POST /__reset; independent of workload profile. `user` and `multiuser-admin` also turn on + * multi-user auth, with a non-admin or an admin session: sign in with any email and password (or reload a page that + * already holds the token). `sharedBoards: true` adds boards other accounts own (`SHARED_BOARD_FIXTURES`), which the + * caller sees through general access, a membership, or admin rights. */ const CAPABILITY_FAULTS = new Set(['ok', 'error', 'empty']); -const INTERMEDIATES_CALLERS = new Set(['admin', 'user']); +const INTERMEDIATES_CALLERS = new Set(['admin', 'user', 'multiuser-admin']); const createFaults = () => ({ capabilities: 'ok', intermediatesCaller: 'admin' }); +const isAdminCaller = (faults) => faults.intermediatesCaller !== 'user'; +const isMultiuserCaller = (faults) => faults.intermediatesCaller !== 'admin'; + +/** Mirrors `board_access_common.py`: the least role each board action needs. */ +const BOARD_ROLE_RANK = { contributor: 2, editor: 3, owner: 4, viewer: 1 }; +const BOARD_ACTION_MINIMUM_ROLE = { + read: 'viewer', + add_media: 'contributor', + manage_items: 'editor', + rename: 'editor', + set_cover: 'editor', + archive: 'editor', + set_general_access: 'owner', + move: 'owner', + delete: 'owner', +}; +/** A project's inbox is managed through its project, by everyone, admins included. */ +const INBOX_MANAGED_ACTIONS = new Set(['rename', 'archive', 'set_general_access', 'move', 'delete']); + +const OTHER_USER_NAMES = { 'user-ana': 'Ana', 'user-ben': 'Ben', 'user-cleo': 'Cleo', 'user-dee': 'Dee' }; + +/** Boards other accounts own: one per way the caller can see one, and one only an admin sees. */ +const SHARED_BOARD_FIXTURES = [ + { + board_id: 'shared-editor', + board_name: 'Brand kit', + general_access: 'restricted', + member_role: 'editor', + user_id: 'user-ana', + }, + { board_id: 'shared-contributor', board_name: 'Team moodboard', general_access: 'contributor', user_id: 'user-ben' }, + { board_id: 'shared-viewer', board_name: 'Client references', general_access: 'viewer', user_id: 'user-cleo' }, + { board_id: 'others-private', board_name: 'Private sketches', general_access: 'restricted', user_id: 'user-dee' }, +]; + +const seedSharedBoards = (state) => { + for (const fixture of SHARED_BOARD_FIXTURES) { + const { member_role: memberRole, ...board } = fixture; + state.boards.set(board.board_id, { + ...board, + archived: false, + created_at: '2026-01-02T00:00:00.000Z', + deleted_at: null, + project_id: null, + updated_at: '2026-01-02T00:00:00.000Z', + }); + if (memberRole) { + state.boardMembers.set(board.board_id, new Map([[MOCK_USER_ID, memberRole]])); + } + } +}; + +/** The caller's access to a board, as `BoardAccessService` reports it. */ +const boardAccessFor = (state, faults, board, { ignoreInbox = false } = {}) => { + const ownerId = board.user_id ?? MOCK_USER_ID; + const isAdmin = isAdminCaller(faults); + const candidates = [ + ...(ownerId === MOCK_USER_ID ? ['owner'] : []), + ...[state.boardMembers.get(board.board_id)?.get(MOCK_USER_ID)].filter(Boolean), + ...((board.general_access ?? 'restricted') === 'restricted' ? [] : [board.general_access]), + ]; + const role = candidates.sort((left, right) => BOARD_ROLE_RANK[right] - BOARD_ROLE_RANK[left])[0] ?? null; + const isInbox = !ignoreInbox && inboxProjectIdForBoard(state, board.board_id) !== null; + const allows = (action) => + !(isInbox && INBOX_MANAGED_ACTIONS.has(action)) && + (isAdmin || (role !== null && BOARD_ROLE_RANK[role] >= BOARD_ROLE_RANK[BOARD_ACTION_MINIMUM_ROLE[action]])); + return { + actions: Object.keys(BOARD_ACTION_MINIMUM_ROLE).filter(allows), + adminOverride: isAdmin && role !== 'owner', + allows, + readable: isAdmin || role !== null, + role, + }; +}; + +/** The server's answer for a board that exists but the caller may not read; only a missing board answers 404. */ +const BOARD_UNREADABLE = [ + 403, + { detail: { code: 'board_unreadable', message: 'Not authorized to access this board' } }, +]; + +/** The server's answer for a readable board whose action the caller's role forbids. */ +const BOARD_ACTION_FORBIDDEN = [ + 403, + { detail: { code: 'board_action_denied', message: 'Not authorized to change this board' } }, +]; + +/** 403 for a board the caller cannot read or an action their role does not allow, else null. */ +const boardRefusal = (state, faults, board, action) => { + const access = boardAccessFor(state, faults, board); + if (!access.readable) { + return BOARD_UNREADABLE; + } + return access.allows(action) ? null : BOARD_ACTION_FORBIDDEN; +}; + +/** + * Mirrors the server's claim rule: an import commits by adopting a staging board, which must be the caller's own, + * unshared (restricted, with no members), in no project, and nobody's inbox. + */ +const boardClaimRefusal = (state, board) => + (board.user_id ?? MOCK_USER_ID) !== MOCK_USER_ID || + (board.general_access ?? 'restricted') !== 'restricted' || + (state.boardMembers.get(board.board_id)?.size ?? 0) > 0 || + inboxProjectIdForBoard(state, board.board_id) !== null || + (board.project_id ?? null) !== null + ? [409, { detail: 'Board is not available to be claimed by a project' }] + : null; + const MOCK_USER_ID = 'fixture-user'; /** The intermediates rows a summary shows under its filters; a `matching` preview scope resolves the same way. */ @@ -116,6 +253,7 @@ const createState = (profile) => { const fixture = assertMockBackendFixture(createMockBackendFixture(profile)); return { + boardMembers: new Map(), boards: new Map(fixture.boards.map((board) => [board.board_id, clone(board)])), // Dismiss the alpha notice and What's New notes so journeys start on the requested page. clientState: new Map([ @@ -225,10 +363,12 @@ const summaryOf = (project) => ({ board_id: project.board_id, created_at: project.created_at, minimum_canvas_schema_version: project.minimum_canvas_schema_version ?? DEFAULT_CANVAS_SCHEMA_VERSION, + minimum_document_schema_version: project.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION, name: project.name, project_id: project.project_id, revision: project.revision, updated_at: project.updated_at, + workspace_updated_at: project.workspace?.updated_at ?? null, }); const readBody = (request) => @@ -607,7 +747,7 @@ const boardSnapshotItems = (state, boardId) => { return items.sort((left, right) => left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name)); }; -const boardDto = (state, board) => { +const boardDto = (state, board, faults) => { const images = [...state.images.values()].filter((image) => image.board_id === board.board_id); const videos = [...state.videos.values()].filter( (video) => video.board_id === board.board_id && video.owner_user_id === MOCK_USER_ID @@ -634,16 +774,27 @@ const boardDto = (state, board) => { )[0]; const projectId = board.project_id ?? null; + const access = boardAccessFor(state, faults, board); + const ownerId = board.user_id ?? MOCK_USER_ID; + const generalAccess = board.general_access ?? 'restricted'; return { ...board, + admin_override: access.adminOverride, + allowed_actions: access.actions, + board_visibility: { contributor: 'public', restricted: 'private', viewer: 'shared' }[generalAccess], + general_access: generalAccess, + my_role: access.role, + owner_username: ownerId === MOCK_USER_ID ? 'Fixture User' : (OTHER_USER_NAMES[ownerId] ?? 'Unknown user'), + user_id: ownerId, asset_count: images.filter((image) => image.image_category !== 'general').length, cover_image_name: cover?.kind === 'image' ? cover.name : null, cover_video_name: cover?.kind === 'video' ? cover.name : null, image_count: images.filter((image) => image.image_category === 'general').length, is_inbox: inboxProjectIdForBoard(state, board.board_id) !== null, - // Membership is stored on the board; the backend sends null for a Library board. - project_id: projectId, + // Membership is stored on the board; the backend sends null for a Library board, and to anyone but the owner + // and admins. + project_id: access.role === 'owner' || isAdminCaller(faults) ? projectId : null, video_count: videos.length, }; }; @@ -833,6 +984,10 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const requested = url.searchParams.get('capabilities') ?? body.capabilities; const caller = url.searchParams.get('intermediatesCaller') ?? body.intermediatesCaller; + if (body.sharedBoards === true && !state.boards.has(SHARED_BOARD_FIXTURES[0].board_id)) { + seedSharedBoards(state); + } + if (requested !== undefined && requested !== null) { if (!CAPABILITY_FAULTS.has(requested)) { return json(400, { detail: `Unknown capabilities fault: ${String(requested)}` }); @@ -853,6 +1008,28 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, { ok: true, faults: { ...faults } }); } + // Changes what another account's board grants the caller behind the page's back: a share or a revocation. + if (method === 'POST' && path === '/__board-access') { + const body = await readJsonBody(request); + const board = state.boards.get(body.board_id); + if (!board) { + return json(404, { detail: 'Board not found' }); + } + if (body.general_access !== undefined) { + board.general_access = body.general_access; + } + if (body.member_role !== undefined) { + const members = state.boardMembers.get(board.board_id) ?? new Map(); + if (body.member_role === null) { + members.delete(MOCK_USER_ID); + } else { + members.set(MOCK_USER_ID, body.member_role); + } + state.boardMembers.set(board.board_id, members); + } + return json(200, { ok: true }); + } + if (method === 'GET' && path === '/openapi.json') { return json(200, state.openApiDocument); } @@ -860,19 +1037,20 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (method === 'GET' && path === '/api/v1/auth/status') { return json(200, { admin_email: null, - multiuser_enabled: faults.intermediatesCaller === 'user', + multiuser_enabled: isMultiuserCaller(faults), setup_required: false, strict_password_checking: false, }); } - if (faults.intermediatesCaller === 'user' && path.startsWith('/api/v1/auth/')) { + if (isMultiuserCaller(faults) && path.startsWith('/api/v1/auth/')) { + const sessionUser = { ...mockNonAdminUser(), is_admin: isAdminCaller(faults) }; if (method === 'POST' && path === '/api/v1/auth/login') { - return json(200, { expires_in: 86_400, token: MOCK_USER_TOKEN, user: mockNonAdminUser() }); + return json(200, { expires_in: 86_400, token: MOCK_USER_TOKEN, user: sessionUser }); } if (method === 'GET' && path === '/api/v1/auth/me') { return request.headers.authorization === `Bearer ${MOCK_USER_TOKEN}` - ? json(200, mockNonAdminUser()) + ? json(200, sessionUser) : json(401, { detail: 'Not authenticated' }); } if (method === 'POST' && (path === '/api/v1/auth/media-cookie' || path === '/api/v1/auth/logout')) { @@ -1008,9 +1186,34 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const now = timestamp(state); const projectNumber = state.nextProjectNumber; const name = requested.name ?? `Project Name #${projectNumber}`; + const projectId = requested.project_id ?? `mock-project-${projectNumber}`; + + // Refusals come before anything is written: a refused create claims no board, as the server's one + // transaction would not. + const minimum = requested.minimum_canvas_schema_version ?? DEFAULT_CANVAS_SCHEMA_VERSION; + const maximum = clientMaximum(requested.max_canvas_schema_version); + if (minimum > maximum) { + return json(...schemaUnsupported(minimum, maximum)); + } + const documentFloor = requested.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION; + if (documentFloor > documentMaximum(requested.max_document_schema_version)) { + return json( + ...documentSchemaUnsupported(documentFloor, documentMaximum(requested.max_document_schema_version)) + ); + } + // The caller's first workspace, stored with the content: only beside split content, as the server has it. + const workspaceData = requested.workspace ?? null; + if (workspaceData !== null && documentFloor < SPLIT_DOCUMENT_SCHEMA_VERSION) { + return json(400, { + detail: { + code: 'workspace_needs_split_document', + message: 'A workspace is saved only with content at document schema 4 or newer.', + minimum_document_schema_version: documentFloor, + }, + }); + } // Adopting a prepopulated board makes project creation the import commit point. - const projectId = requested.project_id ?? `mock-project-${projectNumber}`; let boardId = requested.board_id ?? null; if (boardId === null) { boardId = `mock-project-board-${projectNumber}`; @@ -1018,7 +1221,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { archived: false, board_id: boardId, board_name: name, - board_visibility: 'private', + general_access: 'restricted', created_at: now, deleted_at: null, owner_username: null, @@ -1031,39 +1234,33 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (!board) { return json(404, { detail: 'Board not found' }); } - if ( - board.board_visibility !== 'private' || - inboxProjectIdForBoard(state, boardId) !== null || - (board.project_id ?? null) !== null - ) { - return json(409, { detail: 'Board is not available to be claimed by a project' }); + const claimRefusal = boardClaimRefusal(state, board); + if (claimRefusal) { + return json(...claimRefusal); } board.board_name = name; board.project_id = projectId; board.updated_at = now; } - const minimum = requested.minimum_canvas_schema_version ?? DEFAULT_CANVAS_SCHEMA_VERSION; - const maximum = clientMaximum(requested.max_canvas_schema_version); - if (minimum > maximum) { - return schemaUnsupported(minimum, maximum); - } - const project = { board_id: boardId, created_at: now, data: requested.data ?? {}, minimum_canvas_schema_version: minimum, + minimum_document_schema_version: documentFloor, name, project_id: projectId, revision: 1, updated_at: now, + workspace: workspaceData === null ? null : { data: workspaceData, revision: 1, updated_at: now }, }; state.nextProjectNumber += 1; state.projects.set(project.project_id, project); - return json(200, project); + // The answer carries the workspace this create stored, null when it carried none. + return json(200, { ...writtenRecordOf(project), workspace: project.workspace }); } } @@ -1079,9 +1276,14 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const maximum = clientMaximum( Number(url.searchParams.get('max_canvas_schema_version') ?? DEFAULT_CANVAS_SCHEMA_VERSION) ); - return project.minimum_canvas_schema_version > maximum - ? schemaUnsupported(project.minimum_canvas_schema_version, maximum) - : json(200, project); + const documentFloor = project.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION; + const documentClient = documentMaximum(Number(url.searchParams.get('max_document_schema_version'))); + if (project.minimum_canvas_schema_version > maximum) { + return json(...schemaUnsupported(project.minimum_canvas_schema_version, maximum)); + } + return documentFloor > documentClient + ? json(...documentSchemaUnsupported(documentFloor, documentClient)) + : json(200, { ...writtenRecordOf(project), workspace: project.workspace ?? null }); } if (method === 'PUT') { if (!project) { @@ -1091,11 +1293,62 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const requested = await readJsonBody(request); if (requested.expected_revision !== undefined && requested.expected_revision !== project.revision) { - return json(409, { detail: 'Revision conflict' }); + return json(409, { detail: { current_revision: project.revision, message: 'Revision conflict' } }); + } + // The caller's workspace, saved in the same write as the content: a revision mismatch refuses both. + const workspaceRequest = requested.workspace ?? null; + const currentWorkspaceRevision = project.workspace?.revision ?? null; + if (workspaceRequest && currentWorkspaceRevision !== (workspaceRequest.expected_revision ?? null)) { + return json(409, { + detail: { current_revision: currentWorkspaceRevision, message: 'Workspace revision conflict' }, + }); } const maximum = clientMaximum(requested.max_canvas_schema_version); if (project.minimum_canvas_schema_version > maximum) { - return schemaUnsupported(project.minimum_canvas_schema_version, maximum); + return json(...schemaUnsupported(project.minimum_canvas_schema_version, maximum)); + } + const documentClient = documentMaximum(requested.max_document_schema_version); + const currentDocumentFloor = project.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION; + if (currentDocumentFloor > documentClient) { + return json(...documentSchemaUnsupported(currentDocumentFloor, documentClient)); + } + const requestedDocumentFloor = requested.minimum_document_schema_version ?? currentDocumentFloor; + if (requestedDocumentFloor < currentDocumentFloor) { + return json(400, { + detail: { + code: 'document_schema_downgrade', + current_minimum_document_schema_version: currentDocumentFloor, + message: 'A project never lowers its document schema floor.', + requested_minimum_document_schema_version: requestedDocumentFloor, + }, + }); + } + if (requestedDocumentFloor > documentClient) { + return json(...documentSchemaUnsupported(requestedDocumentFloor, documentClient)); + } + const documentFloor = requestedDocumentFloor; + // As the server: a workspace is stored only beside split content, and the split never lands without one. + if (workspaceRequest && documentFloor < SPLIT_DOCUMENT_SCHEMA_VERSION) { + return json(400, { + detail: { + code: 'workspace_needs_split_document', + message: 'A workspace is saved only with content at document schema 4 or newer.', + minimum_document_schema_version: documentFloor, + }, + }); + } + if ( + !workspaceRequest && + currentDocumentFloor < SPLIT_DOCUMENT_SCHEMA_VERSION && + documentFloor >= SPLIT_DOCUMENT_SCHEMA_VERSION + ) { + return json(400, { + detail: { + code: 'split_needs_workspace', + message: 'The save that splits a whole project must carry the caller’s workspace.', + minimum_document_schema_version: currentDocumentFloor, + }, + }); } const requestedMinimum = requested.minimum_canvas_schema_version; if (requestedMinimum !== undefined && requestedMinimum < project.minimum_canvas_schema_version) { @@ -1109,14 +1362,22 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { }); } if (requestedMinimum !== undefined && requestedMinimum > maximum) { - return schemaUnsupported(requestedMinimum, maximum); + return json(...schemaUnsupported(requestedMinimum, maximum)); } project.data = requested.data ?? project.data; project.name = requested.name ?? project.name; project.minimum_canvas_schema_version = requestedMinimum ?? project.minimum_canvas_schema_version; + project.minimum_document_schema_version = documentFloor; project.revision += 1; project.updated_at = timestamp(state); + if (workspaceRequest) { + project.workspace = { + data: workspaceRequest.data ?? {}, + revision: (currentWorkspaceRevision ?? 0) + 1, + updated_at: project.updated_at, + }; + } // The board's name tracks the project's; the two commit together. const board = state.boards.get(project.board_id); @@ -1125,7 +1386,8 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { board.updated_at = project.updated_at; } - return json(200, project); + // The answer carries the workspace this write saved, null when it carried none. + return json(200, { ...writtenRecordOf(project), workspace: workspaceRequest ? project.workspace : null }); } if (method === 'DELETE') { if (project) { @@ -1164,6 +1426,36 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } } + const workspaceMatch = /^\/api\/v1\/projects\/([^/]+)\/workspace$/.exec(path); + if (method === 'PUT' && workspaceMatch) { + const project = state.projects.get(decodeURIComponent(workspaceMatch[1])); + if (!project) { + return json(404, { detail: 'Project not found' }); + } + const requested = await readJsonBody(request); + // The workspace holds canvas state, so it answers the canvas floor as the content routes do. + const canvasMaximum = clientMaximum(requested.max_canvas_schema_version); + if (project.minimum_canvas_schema_version > canvasMaximum) { + return json(...schemaUnsupported(project.minimum_canvas_schema_version, canvasMaximum)); + } + const documentFloor = project.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION; + if (documentFloor > documentMaximum(requested.max_document_schema_version)) { + return json( + ...documentSchemaUnsupported(documentFloor, documentMaximum(requested.max_document_schema_version)) + ); + } + const current = project.workspace?.revision ?? null; + if (current !== (requested.expected_revision ?? null)) { + return json(409, { detail: { current_revision: current, message: 'Workspace revision conflict' } }); + } + project.workspace = { + data: requested.data ?? {}, + revision: (current ?? 0) + 1, + updated_at: timestamp(state), + }; + return json(200, project.workspace); + } + const boardSnapshotMatch = /^\/api\/v1\/projects\/([^/]+)\/board-snapshot$/.exec(path); if (method === 'GET' && boardSnapshotMatch) { const project = state.projects.get(decodeURIComponent(boardSnapshotMatch[1])); @@ -1496,7 +1788,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (method === 'GET' && path === '/api/v1/intermediates/summary') { // Mirrors IntermediatesService.get_summary: non-admins see only their rows and may not name another owner. - const isAdmin = faults.intermediatesCaller === 'admin'; + const isAdmin = isAdminCaller(faults); const ownerId = url.searchParams.get('owner_id'); if (!isAdmin && ownerId !== null && ownerId !== MOCK_USER_ID) { return json(403, { detail: 'Only administrators can inspect other accounts' }); @@ -1554,7 +1846,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (method === 'POST' && path === '/api/v1/intermediates/previews') { const requested = await readJsonBody(request); // Mirrors IntermediatesService._authorize_scope. - const isAdmin = faults.intermediatesCaller === 'admin'; + const isAdmin = isAdminCaller(faults); const scope = requested?.scope ?? {}; if (scope.kind === 'everyone' && !isAdmin) { return json(403, { detail: "Only administrators can clear everyone's intermediates" }); @@ -1764,7 +2056,9 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (method === 'GET') { return json( 200, - [...state.boards.values()].map((board) => boardDto(state, board)) + [...state.boards.values()] + .filter((board) => boardAccessFor(state, faults, board).readable) + .map((board) => boardDto(state, board, faults)) ); } if (method === 'POST') { @@ -1778,7 +2072,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { archived: false, board_id: boardId, board_name: url.searchParams.get('board_name') ?? `Board ${state.nextBoardNumber}`, - board_visibility: 'private', + general_access: 'restricted', created_at: now, deleted_at: null, owner_username: null, @@ -1790,7 +2084,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { state.nextBoardNumber += 1; state.boards.set(boardId, board); - return json(201, boardDto(state, board)); + return json(201, boardDto(state, board, faults)); } } @@ -1802,33 +2096,44 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (!board) { return json(404, { detail: 'Board not found' }); } + if (!boardAccessFor(state, faults, board).readable) { + return json(...BOARD_UNREADABLE); + } if (method === 'GET') { - return json(200, boardDto(state, board)); + return json(200, boardDto(state, board, faults)); } if (method === 'PATCH') { const changes = await readJsonBody(request); const moves = Object.hasOwn(changes, 'project_id'); - - // An inbox takes its name, archived state and visibility from the project and cannot move. + const requiredActions = [ + ...(changes.board_name !== undefined ? ['rename'] : []), + ...(changes.archived !== undefined ? ['archive'] : []), + ...(changes.cover_image_name !== undefined ? ['set_cover'] : []), + ...(changes.general_access !== undefined ? ['set_general_access'] : []), + ...(moves ? ['move'] : []), + ]; + // As the router does: the caller's role first, as if the board were not an inbox; then the inbox, which takes + // its name, archived state and access from the project and cannot move, refuses what its project owns. + for (const action of requiredActions) { + if (!boardAccessFor(state, faults, board, { ignoreInbox: true }).allows(action)) { + return json(...BOARD_ACTION_FORBIDDEN); + } + } if ( inboxProjectIdForBoard(state, boardId) !== null && (changes.board_name !== undefined || changes.archived !== undefined || - changes.board_visibility !== undefined || + changes.general_access !== undefined || moves) ) { return json(409, { detail: "This board is the project's inbox" }); } const nextProjectId = moves ? changes.project_id : (board.project_id ?? null); - const nextVisibility = changes.board_visibility ?? board.board_visibility; - if (nextProjectId !== null && nextVisibility !== 'private') { - return json(409, { detail: 'Boards in a project must be private and unshared' }); - } if (nextProjectId !== null && !state.projects.has(nextProjectId)) { return json(404, { detail: 'Project not found' }); } - for (const key of ['archived', 'board_name', 'board_visibility', 'cover_image_name']) { + for (const key of ['archived', 'board_name', 'cover_image_name', 'general_access']) { if (changes[key] !== undefined) { board[key] = changes[key]; } @@ -1838,9 +2143,12 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } board.updated_at = timestamp(state); - return json(201, boardDto(state, board)); + return json(201, boardDto(state, board, faults)); } if (method === 'DELETE') { + if (!boardAccessFor(state, faults, board, { ignoreInbox: true }).allows('delete')) { + return json(...BOARD_ACTION_FORBIDDEN); + } if (inboxProjectIdForBoard(state, boardId) !== null) { // Refused before any media is touched — the whole point of the ordering. return json(409, { detail: "This board is the project's inbox" }); @@ -1930,6 +2238,11 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const suffix = String(state.nextImageNumber).padStart(3, '0'); const imageName = `fixture-upload-${suffix}-${requestedName.replaceAll(/[^a-zA-Z0-9._-]/g, '-')}.png`; const boardId = url.searchParams.get('board_id'); + const uploadBoard = boardId ? state.boards.get(boardId) : undefined; + const uploadRefusal = uploadBoard ? boardRefusal(state, faults, uploadBoard, 'add_media') : null; + if (uploadRefusal) { + return json(...uploadRefusal); + } const now = timestamp(state); const image = { board_id: boardId && state.boards.has(boardId) ? boardId : null, @@ -1989,6 +2302,11 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (boardId !== null && !state.boards.has(boardId)) { return json(404, { detail: 'Board not found' }); } + const copyRefusal = + boardId === null ? null : boardRefusal(state, faults, state.boards.get(boardId), 'add_media'); + if (copyRefusal) { + return json(...copyRefusal); + } const copied = []; const failed = []; @@ -2032,6 +2350,11 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (boardId !== null && !state.boards.has(boardId)) { return json(404, { detail: 'Board not found' }); } + const copyRefusal = + boardId === null ? null : boardRefusal(state, faults, state.boards.get(boardId), 'add_media'); + if (copyRefusal) { + return json(...copyRefusal); + } const copied = []; const failed = []; @@ -2117,6 +2440,11 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const suffix = String(state.nextVideoNumber).padStart(3, '0'); const videoName = `fixture-upload-${suffix}-${requestedName.replaceAll(/[^a-zA-Z0-9._-]/g, '-')}`; const boardId = url.searchParams.get('board_id'); + const uploadBoard = boardId ? state.boards.get(boardId) : undefined; + const uploadRefusal = uploadBoard ? boardRefusal(state, faults, uploadBoard, 'add_media') : null; + if (uploadRefusal) { + return json(...uploadRefusal); + } const video = { board_id: boardId && state.boards.has(boardId) ? boardId : null, created_at: timestamp(state), @@ -2210,6 +2538,10 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (!body.board_id || !state.boards.has(body.board_id)) { return json(404, { detail: 'Board not found' }); } + const attachRefusal = boardRefusal(state, faults, state.boards.get(body.board_id), 'add_media'); + if (attachRefusal) { + return json(...attachRefusal); + } video.board_id = body.board_id; return json(200, { added_videos: [video.video_name], diff --git a/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs b/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs index db10cf41950..da91af29ddd 100644 --- a/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs +++ b/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs @@ -199,6 +199,11 @@ const writeArchiveWithout = async (entries, omitted, targetPath) => { }; const getBoardSnapshot = (projectId) => fetchJson(`/api/v1/projects/${encodeURIComponent(projectId)}/board-snapshot`); +/** A project as this build reads it: content and the caller's workspace. */ +const getProjectRecord = (projectId) => + fetchJson( + `/api/v1/projects/${encodeURIComponent(projectId)}?max_canvas_schema_version=4&max_document_schema_version=4` + ); const getLayerImageNames = (project) => collectCanvasLeaves(project.data.canvas.document).map((layer) => layer.source.image.imageName); @@ -235,7 +240,7 @@ const waitForSavedWorkflows = async (projectId, predicate, label) => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { - const record = await fetchJson(`/api/v1/projects/${encodeURIComponent(projectId)}`); + const record = await getProjectRecord(projectId); if (predicate(record)) { return record; @@ -305,6 +310,10 @@ const runRoundTrip = async ({ backend, browser, contexts, errors, tempDirectory await exportPage.getByRole('menuitem', { exact: true, name: 'Export' }).click(); await exportPage.getByRole('dialog', { name: `Export ${sourceProjectName}`, exact: true }).waitFor(); assert.equal(await exportPage.getByRole('checkbox', { name: 'Include font files', exact: true }).isChecked(), false); + assert.equal( + await exportPage.getByRole('checkbox', { name: 'Include my settings for this project', exact: true }).isChecked(), + true + ); await exportPage.getByRole('button', { name: 'Export project', exact: true }).click(); const download = await downloadPromise; @@ -322,8 +331,19 @@ const runRoundTrip = async ({ backend, browser, contexts, errors, tempDirectory const bundledImages = Object.keys(entries).filter((name) => name.startsWith('images/')); const bundledVideos = Object.keys(entries).filter((name) => name.startsWith('videos/')); - assert.equal(manifest.version, 2); + assert.equal(manifest.version, 3); + assert.equal(manifest.documentSchemaVersion, 4); assert.equal(manifest.contents, 'workbench-project'); + // The content, and beside it the exporter's workspace: which workflow they had open. + const archivedContent = readEntryJson(entries, 'project.json'); + const archivedWorkspace = readEntryJson(entries, 'workspace.json'); + assert.equal(archivedContent.documentSchemaVersion, 4); + assert.equal(archivedContent.layout, undefined); + assert.ok( + archivedContent.workflows.entries.some( + (entry) => entry.document.id === archivedWorkspace.workflows.activeWorkflowId + ) + ); assert.equal(archivedBoard.version, 2); // The inbox leads, then the project's other board, each with its own media. assert.deepEqual( @@ -385,7 +405,8 @@ const runRoundTrip = async ({ backend, browser, contexts, errors, tempDirectory await importPage.getByRole('main', { exact: true, name: summary.name }).waitFor(); assert.equal(new URL(importPage.url()).hash, '#/app'); - const imported = await fetchJson(`/api/v1/projects/${encodeURIComponent(summary.project_id)}`); + // Import saves the content and then the importer's workspace before it opens the project. + const imported = await getProjectRecord(summary.project_id); const importedBoard = await getBoardSnapshot(summary.project_id); const importedBoardNames = snapshotItems(importedBoard).map((item) => item.name); @@ -454,11 +475,13 @@ const runRoundTrip = async ({ backend, browser, contexts, errors, tempDirectory contexts.delete(importContext); assertNoBrowserErrors(errors); - // The fixture was a schema-2 document with one `projectGraph`; import canonicalizes it into the current - // schema with that graph as the only workflow and no library write target. - assert.equal(imported.data.documentSchemaVersion, 3); + // The fixture was a schema-2 document with one `projectGraph`; export canonicalized it with that graph as the only + // workflow and no library write target, and import created it split, with the exporter's workspace as the + // importer's own. + assert.equal(imported.data.documentSchemaVersion, 4); + assert.equal(imported.minimum_document_schema_version, 4); assert.equal(imported.data.workflows.entries.length, 1); - assert.equal(imported.data.workflows.activeWorkflowId, imported.data.workflows.entries[0].document.id); + assert.equal(imported.workspace?.data.workflows.activeWorkflowId, imported.data.workflows.entries[0].document.id); assert.equal(imported.data.workflows.entries[0].source, undefined); await runDuplication({ browser, contexts, errors, imported, importedBoardNames }); @@ -524,7 +547,7 @@ const runDuplication = async ({ browser, contexts, errors, imported, importedBoa assert.equal(importedBoardNames.includes(name), false, `${name} is shared with the project it was copied from.`); } - const copiedRecord = await fetchJson(`/api/v1/projects/${encodeURIComponent(copy.project_id)}`); + const copiedRecord = await getProjectRecord(copy.project_id); const copiedLayers = getLayerImageNames(copiedRecord); const importedLayers = getLayerImageNames(imported); @@ -681,13 +704,14 @@ const runWorkflowCollection = async ({ browser, contexts, errors, imported }) => 'two workflows with the saved copy linked to its new template' ); - assert.equal(record.data.documentSchemaVersion, 3); + // Saved split: the content holds the workflows, and the workspace which one this account has open. + assert.equal(record.data.documentSchemaVersion, 4); assert.deepEqual( record.data.workflows.entries.map((entry) => entry.document.name), ['Empty Workflow', 'Journey Saved'] ); assert.deepEqual(record.data.workflows.entries[1].source, { libraryWorkflowId: saved.workflow_id, revision: 1 }); - assert.equal(record.data.workflows.activeWorkflowId, record.data.workflows.entries[1].document.id); + assert.equal(record.workspace.data.workflows.activeWorkflowId, record.data.workflows.entries[1].document.id); // Restart: both workflows and the active selection come back from the server. await page.reload({ waitUntil: 'domcontentloaded' }); diff --git a/invokeai/frontend/webv2/src/app/GalleryUiAdapter.browser.test.tsx b/invokeai/frontend/webv2/src/app/GalleryUiAdapter.browser.test.tsx index caf9b05316d..da1e863bedb 100644 --- a/invokeai/frontend/webv2/src/app/GalleryUiAdapter.browser.test.tsx +++ b/invokeai/frontend/webv2/src/app/GalleryUiAdapter.browser.test.tsx @@ -38,6 +38,11 @@ vi.mock('@workbench/widgets/preview/livePreviewFollow', () => ({ showAll: vi.fn(), }), })); +const session = vi.hoisted(() => ({ multiuserEnabled: false })); +vi.mock('@features/identity', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuthSession: () => session, +})); vi.mock('@workbench/projects/useProjectFileActions', () => ({ useExportLibraryProject: () => noop })); vi.mock('@workbench/useOpenWorkbenchWidget', () => ({ useOpenWorkbenchWidget: () => noop })); vi.mock('./GalleryImageActionsBridge', () => ({ @@ -117,3 +122,16 @@ describe('Gallery adapter subscriptions', () => { expect(adapter.galleryValues).toBe(galleryAdapter.galleryValues); }); }); + +describe('board sharing', () => { + it.each([true, false])( + 'tells the gallery boards can be shared exactly when the server has accounts (%s)', + async (multiuser) => { + session.multiuserEnabled = multiuser; + + await renderAdapter(); + + expect(adapter.boardSharing).toBe(multiuser); + } + ); +}); diff --git a/invokeai/frontend/webv2/src/app/GalleryUiAdapter.tsx b/invokeai/frontend/webv2/src/app/GalleryUiAdapter.tsx index 7f6469b0ecd..38fd8ba6715 100644 --- a/invokeai/frontend/webv2/src/app/GalleryUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/GalleryUiAdapter.tsx @@ -56,13 +56,12 @@ export const GalleryUiAdapterProvider = ({ children }: { children: ReactNode }) liveFollowEnabled: project.settings.showProgressImagesInViewer, })); const livePreview = useLivePreviewFollow(); + const boardSharing = useAuthSession().multiuserEnabled; const { gallery, notifications, widgets } = useWorkbenchCommands(); const queries = useWorkbenchQueries(); const accountScope = captureAccountScope(); const exportProject = useExportLibraryProject(); const openWorkbenchWidget = useOpenWorkbenchWidget(); - const session = useAuthSession(); - const currentUserId = session.multiuserEnabled ? (session.user?.user_id ?? null) : null; // Only what the gallery needs to label other projects' boards, compared structurally: every autosave ack and // library refresh rebuilds the summaries, and the gallery must not re-render for cover or timestamp churn. const projects = useProjectLibrarySelector(selectGalleryProjects, areGalleryProjectsEqual); @@ -75,7 +74,7 @@ export const GalleryUiAdapterProvider = ({ children }: { children: ReactNode }) const adapter = useMemo( () => ({ antialiasProgressImages, - currentUserId, + boardSharing, exportProject, gallery: { ...gallery, @@ -134,7 +133,7 @@ export const GalleryUiAdapterProvider = ({ children }: { children: ReactNode }) [ accountScope, antialiasProgressImages, - currentUserId, + boardSharing, exportProject, gallery, galleryValues, diff --git a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.browser.test.tsx b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.browser.test.tsx index ba5c3e86798..e41a93e0e11 100644 --- a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.browser.test.tsx +++ b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.browser.test.tsx @@ -1,8 +1,10 @@ +import type { GalleryBoard } from '@features/gallery/contracts'; import type { GenerationUiAdapter } from '@features/generation/react'; import type { QueueServerItemDTO } from '@features/queue/data/serverTypes'; import type { Project } from '@workbench/projectContracts'; import type { ReactNode } from 'react'; +import { galleryBoardsOptions } from '@features/gallery/queries'; import { getQueueReadModelOptions } from '@features/queue'; import { buildProjectQueueItemOriginPrefix } from '@features/queue/contracts'; import { createQueueItemDTO, createQueueServer } from '@features/queue/data/queueServer.testing'; @@ -151,6 +153,7 @@ describe('Generation adapter queue insights', () => { await render(null); await invalidateQueue(); + // Nor anything else: the board listing Save to Uploads is judged from is read as an upload starts, not observed. expect(server.requests).toEqual([]); }); @@ -280,3 +283,45 @@ describe('Generation adapter queue insights', () => { expect(adapter.queueInsights.getSnapshot().seedHistory).toBe(before.seedHistory); }); }); + +describe('Generation adapter upload board', () => { + const board = (id: string, canAdd: boolean): GalleryBoard => ({ + access: { + actions: canAdd ? ['read', 'add_media'] : ['read'], + adminOverride: false, + generalAccess: 'viewer', + role: 'viewer', + }, + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 0, + isInbox: id === 'inbox', + kind: 'board', + name: id, + projectId: null, + videoCount: 0, + }); + const listBoards = (sharedCanAdd: boolean) => + queryClient.setQueryData(galleryBoardsOptions({ includeArchived: true }).queryKey, [ + board('inbox', true), + board('shared', sharedCanAdd), + ]); + + it('sends Save to Uploads to the auto-add board while its listing allows it, else to the project inbox', async () => { + const state = createInitialWorkbenchState(); + state.projects[0]!.widgetInstances.gallery!.state.values = { autoAddBoardId: 'shared', projectBoardId: 'inbox' }; + store = createWorkbenchStore(state); + listBoards(true); + + await render(null); + await expect(adapter.gallery.getUploadBoardId()).resolves.toBe('shared'); + + // Access revoked: the next listing read says so, and the next upload follows it without a project change. + listBoards(false); + await expect(adapter.gallery.getUploadBoardId()).resolves.toBe('inbox'); + // Both were judged from the cached listing: nothing was read for them. + expect(server.requests).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx index 6bc339aad57..072abb78e74 100644 --- a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx @@ -4,7 +4,11 @@ import type { ReadableExternalStore } from '@platform/state/projectedExternalSto import type { ReactNode } from 'react'; import { getGalleryAutoAddBoardId, getSelectedGalleryImageFromValues } from '@features/gallery/contracts'; -import { invalidateGallery } from '@features/gallery/queries'; +import { + canAddToCachedGalleryBoard, + ensureGalleryBoardDestinations, + invalidateGallery, +} from '@features/gallery/queries'; import { galleryImageUrls } from '@features/gallery/utility'; import { GenerationUiProvider } from '@features/generation/react'; import { normalizeRebalancePresets } from '@features/generation/settings'; @@ -232,17 +236,31 @@ export const GenerationUiAdapterProvider = ({ children }: { children: ReactNode (imageName) => findGalleryItem({ kind: 'image', name: imageName }), [findGalleryItem] ); - const uploadBoardId = useActiveProjectSelector( - (activeProject) => getGalleryAutoAddBoardId(getProjectWidgetValues(activeProject, 'gallery')) ?? 'none' - ); + // Save to Uploads lands where new results do: the auto-add board, judged against the listing of every readable + // board, ensured as the upload starts so an archived or revoked board is decided from a listing, not for want of + // one, and no listing is read while nothing uploads; a board the listing refuses falls back to the project's inbox. + // The board is read from the project before anything is awaited, so a later change cannot redirect an upload + // already under way. + const getUploadBoardId = useCallback(async () => { + const galleryValues = getProjectWidgetValues(store.getSnapshot().activeProject, 'gallery'); + const pendingListing = ensureGalleryBoardDestinations(queryClient); + + if (pendingListing) { + await pendingListing; + } + + return ( + getGalleryAutoAddBoardId(galleryValues, (boardId) => canAddToCachedGalleryBoard(boardId, queryClient)) ?? 'none' + ); + }, [queryClient, store]); const galleryGroup = useMemo( () => ({ findImage, + getUploadBoardId, selectedImage: selectedGalleryImage, touchImages: () => void invalidateGallery(queryClient), - uploadBoardId, }), - [findImage, queryClient, selectedGalleryImage, uploadBoardId] + [findImage, getUploadBoardId, queryClient, selectedGalleryImage] ); const openInModelManager = useOpenModelInManager(); const modelsGroup = useMemo( diff --git a/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx b/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx index d8f6ccb0b1c..edd68aa4e04 100644 --- a/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx @@ -1,4 +1,4 @@ -import { invalidateGallery } from '@features/gallery/queries'; +import { invalidateGallery, invalidateGalleryBoardDestinations } from '@features/gallery/queries'; import { getAuthSession } from '@features/identity'; import { modelLoadActivitySink } from '@features/models'; import { nodeExecutionStore } from '@features/nodes'; @@ -122,6 +122,7 @@ export const QueueRuntimeAdapter = () => { ...queue, recordError: notifications.reportError, refreshBackendData: () => void invalidateGallery(queryClient), + refreshBoardDestinations: () => void invalidateGalleryBoardDestinations(queryClient), }, getSnapshot: () => { const snapshot = queries.getSnapshot(); diff --git a/invokeai/frontend/webv2/src/app/VideoUiAdapter.browser.test.tsx b/invokeai/frontend/webv2/src/app/VideoUiAdapter.browser.test.tsx index 80ce1508888..552567a02c2 100644 --- a/invokeai/frontend/webv2/src/app/VideoUiAdapter.browser.test.tsx +++ b/invokeai/frontend/webv2/src/app/VideoUiAdapter.browser.test.tsx @@ -44,14 +44,26 @@ vi.mock('@features/video', async (importOriginal) => ({ return children; }, })); -vi.mock('@features/gallery/queries', () => ({ invalidateGallery: vi.fn() })); +const galleryQueries = vi.hoisted(() => ({ + canAddToCachedGalleryBoard: vi.fn(() => true), + ensureGalleryBoardDestinations: vi.fn((): Promise | null => null), +})); +vi.mock('@features/gallery/queries', () => ({ + canAddToCachedGalleryBoard: galleryQueries.canAddToCachedGalleryBoard, + ensureGalleryBoardDestinations: galleryQueries.ensureGalleryBoardDestinations, + invalidateGallery: vi.fn(), + invalidateGalleryBoardDestinations: vi.fn(() => Promise.resolve()), +})); vi.mock('@workbench/image-actions/revealGalleryItem', () => ({ revealGalleryItem })); vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({}) })); vi.mock('@workbench/settings/store', () => ({ useWorkbenchPreferenceSelector: () => false })); vi.mock('@workbench/useOpenWorkbenchWidget', () => ({ useOpenWorkbenchWidget: () => openWorkbenchWidget })); +/** The active project's widget instances, read by the adapter's project selectors. */ +let widgetInstances: Record = {}; + vi.mock('@workbench/WorkbenchContext', () => ({ useActiveProjectSelector: (selector: (project: unknown) => unknown) => - selector({ id: activeProjectId, widgetInstances: {} }), + selector({ id: activeProjectId, widgetInstances }), useWorkbenchCommands: () => ({ gallery: { selectItem }, notifications: { reportError }, @@ -95,6 +107,9 @@ beforeEach(async () => { reportError.mockClear(); galleryRegions = ['right']; storedProjects = {}; + widgetInstances = {}; + galleryQueries.canAddToCachedGalleryBoard.mockReset().mockReturnValue(true); + galleryQueries.ensureGalleryBoardDestinations.mockReset().mockReturnValue(null); host = document.createElement('div'); document.body.append(host); root = createRoot(host); @@ -140,6 +155,37 @@ describe('readValues', () => { }); }); +describe('getUploadBoardId', () => { + it('resolves the auto-add board from the ensured listing, and the inbox once the listing refuses it', async () => { + widgetInstances = { + 'gallery-1': { state: { values: { autoAddBoardId: 'shared', projectBoardId: 'inbox' } }, typeId: 'gallery' }, + }; + await renderAdapter(); + let settleListing!: () => void; + galleryQueries.ensureGalleryBoardDestinations.mockReturnValueOnce( + new Promise((resolve) => { + settleListing = resolve; + }) + ); + + const pending = adapter.getUploadBoardId(); + let resolved: string | null = null; + void pending.then((boardId) => { + resolved = boardId; + }); + await Promise.resolve(); + + // Judged only once the listing is in: nothing is trusted unseen. + expect(resolved).toBeNull(); + settleListing(); + await expect(pending).resolves.toBe('shared'); + expect(galleryQueries.canAddToCachedGalleryBoard).toHaveBeenCalledWith('shared', expect.anything()); + + galleryQueries.canAddToCachedGalleryBoard.mockReturnValue(false); + await expect(adapter.getUploadBoardId()).resolves.toBe('inbox'); + }); +}); + describe('videoSpanPlayback', () => { it("hands the panel the player's report and its changes", () => { const listener = vi.fn(); diff --git a/invokeai/frontend/webv2/src/app/VideoUiAdapter.tsx b/invokeai/frontend/webv2/src/app/VideoUiAdapter.tsx index 160bb994fab..40af1c58422 100644 --- a/invokeai/frontend/webv2/src/app/VideoUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/VideoUiAdapter.tsx @@ -2,7 +2,12 @@ import type { VideoUiAdapter } from '@features/video'; import type { ReactNode } from 'react'; import { getGalleryAutoAddBoardId, toGalleryItemKey } from '@features/gallery/contracts'; -import { invalidateGallery } from '@features/gallery/queries'; +import { + canAddToCachedGalleryBoard, + ensureGalleryBoardDestinations, + invalidateGallery, + invalidateGalleryBoardDestinations, +} from '@features/gallery/queries'; import { createDefaultVideoWidgetValues, normalizeVideoWidgetValues, VideoUiProvider } from '@features/video'; import { useQueryClient } from '@tanstack/react-query'; import { useFindGalleryItem } from '@workbench/image-actions/useFindGalleryItem'; @@ -13,10 +18,12 @@ import { requestVideoSpanPlayback, subscribeVideoSpanPlaybackState, } from '@workbench/widgets/preview/spanPlaybackRequest'; -import { getProjectWidgetValues } from '@workbench/widgetState'; +import { getProjectWidgetInstance, getProjectWidgetValues } from '@workbench/widgetState'; import { useActiveProjectSelector, useWorkbenchCommands, useWorkbenchQueries } from '@workbench/WorkbenchContext'; import { useCallback, useEffect, useMemo, useRef } from 'react'; +const EMPTY_GALLERY_VALUES: Record = Object.freeze({}); + /** Video owns its prompt in widget values; do not join the draft shared by Generate/Upscale. */ export const VideoUiAdapterProvider = ({ children }: { children: ReactNode }) => { const project = useActiveProjectSelector( @@ -34,18 +41,19 @@ export const VideoUiAdapterProvider = ({ children }: { children: ReactNode }) => const showPromptSyntaxHighlighting = useWorkbenchPreferenceSelector( (preferences) => preferences.showPromptSyntaxHighlighting ); - // Panel uploads land where new results do: the auto-add board. - const uploadBoardId = useActiveProjectSelector( - (activeProject) => getGalleryAutoAddBoardId(getProjectWidgetValues(activeProject, 'gallery')) ?? 'none' + // Panel uploads land where new results do: the auto-add board, resolved when the upload starts, against the board + // listing as it is then. + const galleryValues = useActiveProjectSelector( + (activeProject) => getProjectWidgetInstance(activeProject, 'gallery')?.state?.values ?? EMPTY_GALLERY_VALUES ); - // Read the current board through a ref so board selection does not recreate project actions. - const uploadBoardIdRef = useRef(uploadBoardId); + // Read the gallery values through a ref so board selection does not recreate project actions. + const galleryValuesRef = useRef(galleryValues); // Async clip resolution can outlive a project switch; callbacks must check the live project. const activeProjectIdRef = useRef(project.projectId); useEffect(() => { - uploadBoardIdRef.current = uploadBoardId; + galleryValuesRef.current = galleryValues; activeProjectIdRef.current = project.projectId; - }, [project.projectId, uploadBoardId]); + }, [galleryValues, project.projectId]); const commands = useWorkbenchCommands(); const queries = useWorkbenchQueries(); const queryClient = useQueryClient(); @@ -74,6 +82,10 @@ export const VideoUiAdapterProvider = ({ children }: { children: ReactNode }) => [commands] ); const touchGalleryImages = useCallback(() => void invalidateGallery(queryClient), [queryClient]); + const refreshBoardDestinations = useCallback( + () => void invalidateGalleryBoardDestinations(queryClient), + [queryClient] + ); const openWorkbenchWidget = useOpenWorkbenchWidget(); // Raise Preview before changing selection; refusal must leave selection untouched. Request the span last and do // not reveal/scroll the gallery. @@ -94,7 +106,21 @@ export const VideoUiAdapterProvider = ({ children }: { children: ReactNode }) => }, [commands, openWorkbenchWidget, projectId] ); - const getUploadBoardId = useCallback(() => uploadBoardIdRef.current, []); + // Judged from the listing of every readable board, ensured as the upload starts: an archived or revoked board is + // decided from a listing, not for want of one, and no listing is read while nothing uploads. The board is read + // before anything is awaited, so a later change to the panel cannot redirect an upload already under way. + const getUploadBoardId = useCallback(async () => { + const galleryValues = galleryValuesRef.current; + const pendingListing = ensureGalleryBoardDestinations(queryClient); + + if (pendingListing) { + await pendingListing; + } + + return ( + getGalleryAutoAddBoardId(galleryValues, (boardId) => canAddToCachedGalleryBoard(boardId, queryClient)) ?? 'none' + ); + }, [queryClient]); const findInGallery = useFindGalleryItem(); const adapter = useMemo( () => ({ @@ -104,6 +130,7 @@ export const VideoUiAdapterProvider = ({ children }: { children: ReactNode }) => patchValues, playVideoSpanInPreview, readValues, + refreshBoardDestinations, reportError, showPromptSyntaxHighlighting, touchGalleryImages, @@ -116,6 +143,7 @@ export const VideoUiAdapterProvider = ({ children }: { children: ReactNode }) => playVideoSpanInPreview, project, readValues, + refreshBoardDestinations, reportError, showPromptSyntaxHighlighting, touchGalleryImages, diff --git a/invokeai/frontend/webv2/src/features/gallery/README.md b/invokeai/frontend/webv2/src/features/gallery/README.md index 22e1f60c6f2..2a6d34549a4 100644 --- a/invokeai/frontend/webv2/src/features/gallery/README.md +++ b/invokeai/frontend/webv2/src/features/gallery/README.md @@ -28,3 +28,7 @@ Known limitations: - The status event does not say whether an item is a workflow-call child, which inherits its parent's destination. A child's completion supersedes a reveal still in flight before its read finds it is a child and reveals nothing; if the parent then fails or is cancelled, the earlier item is not revealed. - An image a graph passes through from its inputs counts as an output, so it is picked if it is the last non-intermediate one. + +## Board destinations + +Where results and uploads land is decided from a cached board listing, never guessed: `canAddToCachedGalleryBoard` (`features/gallery/data/queryCache.ts`) takes a board as a destination only when a cached listing holds it with `add_media`, archived or not; a board no listing holds is not one. Submissions and uploads call `ensureGalleryBoardDestinations` first. It resolves optimistically: any cached listing of every readable board (archived included) decides at once, whatever its age; one older than a minute or invalidated is read again in the background for the next resolution; only with no such listing cached is one read awaited, and if that read fails nothing is a destination and results go to the inbox. No surface observes that listing just in case: the Gallery panel's own listing serves when it shows archived boards, and otherwise the first submission or upload reads one. A failed gallery-bound run, workflow run or upload, including one a board refused with 403 or answered 404 for, calls `invalidateGalleryBoardDestinations`: the gallery is invalidated and the listing of every readable board is read again at once, with no observer needed. A submission or upload made after that read lands decides from the new listing; one made before it lands still decides from the old listing and shares the read. The gallery upload action refuses a real board the ensured listing does not take uploads for before sending anything, and says whether the board is view-only or gone. Graph compilation applies the same predicate to explicit workflow board fields; a refused board resolves like Auto. A board the server answers 403 for exists but is unreadable: it stays named by the project and is shown as the project board meanwhile; only a 404 (deleted) reconciles the project's references. Hosts without board sharing fold every board into the caller's own through `useHostGalleryBoards`, which the panel, board menus and workflow board fields share. diff --git a/invokeai/frontend/webv2/src/features/gallery/contracts.ts b/invokeai/frontend/webv2/src/features/gallery/contracts.ts index 6be1ae2ac09..6ee8c7df4d5 100644 --- a/invokeai/frontend/webv2/src/features/gallery/contracts.ts +++ b/invokeai/frontend/webv2/src/features/gallery/contracts.ts @@ -1,5 +1,7 @@ export type { GalleryBoard, + GalleryBoardAccess, + GalleryBoardAction, GalleryBoardDeletionResult, GalleryBoardKind, GalleryBoardOrderBy, @@ -11,6 +13,7 @@ export type { GeneratedImageContract, GeneratedVideoContract, } from './core/types'; +export { canOnBoard, isOwnGalleryBoard } from './core/boardAccess'; export { getGalleryBoardDestinationGroups, getGalleryBoardLabel, diff --git a/invokeai/frontend/webv2/src/features/gallery/core/boardAccess.test.ts b/invokeai/frontend/webv2/src/features/gallery/core/boardAccess.test.ts new file mode 100644 index 00000000000..9e048f12c57 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/core/boardAccess.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import type { GalleryBoard } from './types'; + +import { asSingleAccountBoards, isOtherUsersGalleryBoard, isOwnGalleryBoard } from './boardAccess'; + +const board = (role: 'owner' | 'viewer' | null): GalleryBoard => ({ + access: { actions: ['read', 'add_media'], adminOverride: role !== 'owner', generalAccess: 'restricted', role }, + archived: false, + assetCount: 0, + assetVideoCount: 0, + id: String(role), + imageCount: 0, + isInbox: false, + kind: 'board', + name: 'B', + projectId: null, + videoCount: 0, +}); + +describe('asSingleAccountBoards', () => { + it('reads every board as the caller’s own, keeping what the server allows', () => { + const [own, other, shared] = asSingleAccountBoards([board('owner'), board(null), board('viewer')]); + + expect(isOtherUsersGalleryBoard(board(null))).toBe(true); + expect([own, other, shared].every((folded) => folded !== undefined && isOwnGalleryBoard(folded))).toBe(true); + expect(other?.access).toMatchObject({ actions: ['read', 'add_media'], adminOverride: false }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/core/boardAccess.ts b/invokeai/frontend/webv2/src/features/gallery/core/boardAccess.ts new file mode 100644 index 00000000000..3acb8ea990b --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/core/boardAccess.ts @@ -0,0 +1,34 @@ +import type { GalleryBoard, GalleryBoardAction } from './types'; + +/* + * The caller's relation to a board, from what the server reports. The server alone decides who may do what; these + * only read its answer. A board without `access` (a virtual board) is the caller's own and allows what it always did. + */ + +/** Whether the server lets the caller take an action on the board. */ +export const canOnBoard = (board: GalleryBoard, action: GalleryBoardAction): boolean => + board.access === undefined || board.access.actions.includes(action); + +/** The caller's own board (or a virtual one). */ +export const isOwnGalleryBoard = (board: GalleryBoard): boolean => + board.access === undefined || board.access.role === 'owner'; + +/** Someone else's board the caller has a role on. A real role wins over admin rights. */ +export const isSharedGalleryBoard = (board: GalleryBoard): boolean => + board.access !== undefined && board.access.role !== null && board.access.role !== 'owner'; + +/** + * The boards as a server with one account sees them: every board is the caller's own. Boards other accounts made + * while the server ran with several come back as an admin's view of someone else's; there is no one else to share + * with or to name, so they read as the caller's, as they did before sharing. + */ +export const asSingleAccountBoards = (boards: readonly GalleryBoard[]): GalleryBoard[] => + boards.map((board) => + board.access === undefined || board.access.role === 'owner' + ? board + : { ...board, access: { ...board.access, adminOverride: false, role: 'owner' } } + ); + +/** Someone else's board the caller sees only because they are an admin. */ +export const isOtherUsersGalleryBoard = (board: GalleryBoard): boolean => + board.access !== undefined && board.access.role === null; diff --git a/invokeai/frontend/webv2/src/features/gallery/core/boardLabels.test.ts b/invokeai/frontend/webv2/src/features/gallery/core/boardLabels.test.ts index 14b61c6c171..cd8a31eb680 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/boardLabels.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/boardLabels.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { GalleryBoard } from './types'; +import type { GalleryBoard, GalleryBoardAction } from './types'; import { getGalleryBoardDestinationGroups, getGalleryBoardLabel, getGalleryProjectGroupLabel } from './boardLabels'; @@ -47,6 +47,21 @@ describe('getGalleryBoardLabel for inboxes', () => { }); }); +describe('getGalleryBoardLabel for someone else’s inbox', () => { + it('keeps its stored name, the project’s, which nothing else on screen names', () => { + const access = { + actions: ['read' as const], + adminOverride: true, + generalAccess: 'restricted' as const, + role: null, + }; + + expect(getGalleryBoardLabel(createBoard({ access, isInbox: true, name: 'Their project' }), t)).toBe( + 'Their project' + ); + }); +}); + describe('getGalleryProjectGroupLabel', () => { const inbox = createBoard({ id: 'inbox', isInbox: true, name: 'Stored name', projectId: 'p2' }); @@ -84,6 +99,35 @@ describe('getGalleryBoardDestinationGroups', () => { ]); }); + it('offers only boards the caller may add to, other people’s apart from their own', () => { + const access = (role: 'viewer' | 'contributor' | null, actions: GalleryBoardAction[]) => ({ + access: { actions, adminOverride: role === null, generalAccess: 'viewer' as const, role }, + }); + const withShared = [ + ...boards, + createBoard({ id: 'moodboard', name: 'Moodboard', ...access('contributor', ['read', 'add_media']) }), + createBoard({ id: 'references', name: 'References', ...access('viewer', ['read']) }), + createBoard({ id: 'theirs', name: 'Theirs', projectId: 'their-project', ...access(null, ['read', 'add_media']) }), + ]; + + expect( + getGalleryBoardDestinationGroups({ + boards: withShared, + projectId: 'p1', + projectName: 'Mine', + projectNames: new Map(), + t, + }).map((group) => [group.id, group.boards.map((board) => board.id)]) + ).toEqual([ + ['project', ['mine', 'mine-member']], + ['library', ['cats']], + ['shared', ['moodboard']], + ['other-users', ['theirs']], + ['project:pa', ['apple-member']], + ['project:pz', ['zebra', 'zebra-member']], + ]); + }); + it('drops empty tiers and treats every project as other where none is open', () => { expect(groupsOf(null)).toEqual([ ['Library', ['cats']], diff --git a/invokeai/frontend/webv2/src/features/gallery/core/boardLabels.ts b/invokeai/frontend/webv2/src/features/gallery/core/boardLabels.ts index 52f1daf43b5..fb8ec5e024f 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/boardLabels.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/boardLabels.ts @@ -1,15 +1,18 @@ import type { GalleryBoard } from './types'; +import { canOnBoard, isOtherUsersGalleryBoard, isOwnGalleryBoard, isSharedGalleryBoard } from './boardAccess'; + export type GalleryBoardTranslate = (key: string) => string; /** * Resolves the localized display label for synthesized and stored boards. An inbox is labelled as such: every - * surface that lists it also names its project, as a section, a group or the open project itself. + * surface that lists one of the caller's also names its project, as a section, a group or the open project itself. + * Someone else's inbox, which only an admin sees, keeps its stored name, the project's: nothing else names it. */ export const getGalleryBoardLabel = (board: GalleryBoard, t: GalleryBoardTranslate): string => board.kind === 'uncategorized' ? t('widgets.gallery.uncategorized') - : board.isInbox + : board.isInbox && isOwnGalleryBoard(board) ? t('widgets.gallery.inbox') : board.name; @@ -39,8 +42,9 @@ export interface GalleryBoardDestinationGroup { } /** - * The tiers a flat "move to board" menu offers, in the panel's order: the open project, the Library, then each - * other project under its own name. Empty groups are dropped so a menu never shows a bare heading. + * The tiers a flat "move to board" menu offers, in the panel's order: the open project, the Library, boards shared + * with the caller, other users' boards (admins), then each other project under its own name. Only boards the caller + * may add media to are destinations. Empty groups are dropped so a menu never shows a bare heading. */ export const getGalleryBoardDestinationGroups = ({ boards, @@ -56,8 +60,11 @@ export const getGalleryBoardDestinationGroups = ({ t: GalleryBoardTranslate; }): GalleryBoardDestinationGroup[] => { const otherProjects = new Map(); + const destinations = boards.filter((board) => canOnBoard(board, 'add_media')); + const own = destinations.filter(isOwnGalleryBoard); + const byName = (left: GalleryBoard, right: GalleryBoard) => left.name.localeCompare(right.name); - for (const board of boards) { + for (const board of own) { if (board.projectId !== null && board.projectId !== projectId) { const group = otherProjects.get(board.projectId); @@ -71,15 +78,25 @@ export const getGalleryBoardDestinationGroups = ({ const groups: GalleryBoardDestinationGroup[] = [ { - boards: inboxFirst(boards.filter((board) => projectId !== null && board.projectId === projectId)), + boards: inboxFirst(own.filter((board) => projectId !== null && board.projectId === projectId)), id: 'project', label: projectName, }, { - boards: boards.filter((board) => board.projectId === null), + boards: own.filter((board) => board.projectId === null), id: 'library', label: t('widgets.gallery.boardGroups.library'), }, + { + boards: destinations.filter(isSharedGalleryBoard).sort(byName), + id: 'shared', + label: t('widgets.gallery.boardGroups.shared'), + }, + { + boards: destinations.filter(isOtherUsersGalleryBoard).sort(byName), + id: 'other-users', + label: t('widgets.gallery.boardGroups.otherUsers'), + }, ...[...otherProjects] .map(([otherProjectId, group]) => ({ boards: inboxFirst(group), diff --git a/invokeai/frontend/webv2/src/features/gallery/core/settings.test.ts b/invokeai/frontend/webv2/src/features/gallery/core/settings.test.ts new file mode 100644 index 00000000000..13737a5c1c2 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/core/settings.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { getGallerySettings, isGalleryBoardSectionOpen, toggleGalleryBoardSection } from './settings'; + +describe('board panel sections', () => { + it('starts other users’ boards collapsed, even for a panel that saved its collapsed groups before', () => { + const settings = getGallerySettings({ collapsedBoardSections: ['dates'] }); + + expect(isGalleryBoardSectionOpen(settings, 'other-users')).toBe(false); + expect(isGalleryBoardSectionOpen(settings, 'shared')).toBe(true); + expect(isGalleryBoardSectionOpen(settings, 'dates')).toBe(false); + }); + + it('records the groups that start collapsed as opened, and the rest as collapsed', () => { + const settings = getGallerySettings({}); + + const opened = getGallerySettings({ ...settings, ...toggleGalleryBoardSection(settings, 'other-users', true) }); + const closed = getGallerySettings({ ...opened, ...toggleGalleryBoardSection(opened, 'shared', false) }); + + expect(isGalleryBoardSectionOpen(opened, 'other-users')).toBe(true); + expect(isGalleryBoardSectionOpen(closed, 'shared')).toBe(false); + expect(closed).toMatchObject({ collapsedBoardSections: ['shared'], expandedBoardSections: ['other-users'] }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/core/settings.ts b/invokeai/frontend/webv2/src/features/gallery/core/settings.ts index 485a3fd5025..3cecbdc8c9b 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/settings.ts @@ -17,10 +17,24 @@ export const toGalleryMediaKind = (filter: GalleryMediaFilter): GalleryItemKind filter === 'all' ? undefined : filter; /** The collapsible groups in the board panel, in render order. */ -export const GALLERY_BOARD_SECTION_IDS = ['project', 'library', 'other-projects', 'dates', 'archived'] as const; +export const GALLERY_BOARD_SECTION_IDS = [ + 'project', + 'library', + 'shared', + 'other-users', + 'other-projects', + 'dates', + 'archived', +] as const; export type GalleryBoardSectionId = (typeof GALLERY_BOARD_SECTION_IDS)[number]; +/** + * Groups that start collapsed, which the panel records as opened rather than collapsed: an admin's view of other + * users' boards stays out of the way of their own on a busy install. + */ +const GALLERY_BOARD_SECTIONS_COLLAPSED_BY_DEFAULT: readonly GalleryBoardSectionId[] = ['other-users']; + /** Height in the stacked layout, width in the wide one — stored separately. */ export const GALLERY_BOARD_PANEL_MIN_HEIGHT_PX = 120; export const GALLERY_BOARD_PANEL_MAX_HEIGHT_PX = 600; @@ -45,6 +59,8 @@ export interface GallerySettings { boardPanelHeightPx: number; boardPanelWidthPx: number; collapsedBoardSections: GalleryBoardSectionId[]; + /** The groups that start collapsed which have been opened. */ + expandedBoardSections: GalleryBoardSectionId[]; imageDensityPercent: number; imageOrderDir: GalleryOrderDir; mediaFilter: GalleryMediaFilter; @@ -71,6 +87,7 @@ export const DEFAULT_GALLERY_SETTINGS: GallerySettings = { boardPanelHeightPx: 280, boardPanelWidthPx: 240, collapsedBoardSections: [], + expandedBoardSections: [], imageDensityPercent: 50, imageOrderDir: 'DESC', mediaFilter: 'all', @@ -101,12 +118,30 @@ const getBoundedSize = ( typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : fallback; /** Filtered to known ids: a stale one would close a section with no way back. */ -const getCollapsedBoardSections = (value: unknown): GalleryBoardSectionId[] => +const getBoardSections = (value: unknown, candidates: readonly GalleryBoardSectionId[]): GalleryBoardSectionId[] => Array.isArray(value) - ? GALLERY_BOARD_SECTION_IDS.filter((sectionId) => - value.some((entry) => isBoardSectionId(entry) && entry === sectionId) - ) - : DEFAULT_GALLERY_SETTINGS.collapsedBoardSections; + ? candidates.filter((sectionId) => value.some((entry) => isBoardSectionId(entry) && entry === sectionId)) + : []; + +export const isGalleryBoardSectionOpen = (settings: GallerySettings, sectionId: GalleryBoardSectionId): boolean => + GALLERY_BOARD_SECTIONS_COLLAPSED_BY_DEFAULT.includes(sectionId) + ? settings.expandedBoardSections.includes(sectionId) + : !settings.collapsedBoardSections.includes(sectionId); + +/** The settings change that opens or closes a group. */ +export const toggleGalleryBoardSection = ( + settings: GallerySettings, + sectionId: GalleryBoardSectionId, + isOpen: boolean +): Pick | Pick => { + const toggle = (sections: GalleryBoardSectionId[], include: boolean) => + include + ? [...sections.filter((entry) => entry !== sectionId), sectionId] + : sections.filter((entry) => entry !== sectionId); + return GALLERY_BOARD_SECTIONS_COLLAPSED_BY_DEFAULT.includes(sectionId) + ? { expandedBoardSections: toggle(settings.expandedBoardSections, isOpen) } + : { collapsedBoardSections: toggle(settings.collapsedBoardSections, !isOpen) }; +}; export const getGallerySettings = (values: Record): GallerySettings => { const paginationMode: GalleryPaginationMode = @@ -133,7 +168,11 @@ export const getGallerySettings = (values: Record): GallerySett max: GALLERY_BOARD_PANEL_MAX_WIDTH_PX, min: GALLERY_BOARD_PANEL_MIN_WIDTH_PX, }), - collapsedBoardSections: getCollapsedBoardSections(values.collapsedBoardSections), + collapsedBoardSections: getBoardSections( + values.collapsedBoardSections, + GALLERY_BOARD_SECTION_IDS.filter((sectionId) => !GALLERY_BOARD_SECTIONS_COLLAPSED_BY_DEFAULT.includes(sectionId)) + ), + expandedBoardSections: getBoardSections(values.expandedBoardSections, GALLERY_BOARD_SECTIONS_COLLAPSED_BY_DEFAULT), imageDensityPercent: typeof values.imageDensityPercent === 'number' && Number.isFinite(values.imageDensityPercent) ? Math.min(100, Math.max(0, values.imageDensityPercent)) diff --git a/invokeai/frontend/webv2/src/features/gallery/core/types.ts b/invokeai/frontend/webv2/src/features/gallery/core/types.ts index c934ea9341d..501c2ffedc1 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/types.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/types.ts @@ -64,8 +64,6 @@ export interface GalleryBoard { coverThumbnailUrl?: string; /** ISO creation timestamp; absent for uncategorized and date virtual boards. */ createdAt?: string | null; - /** The owner's user id; a project only ever holds its owner's boards. */ - ownerId?: string; ownerName?: string | null; /** * The project this board belongs to; null is the Library. Members are ordinary boards; only the inbox is @@ -74,8 +72,36 @@ export interface GalleryBoard { projectId: string | null; /** Its project's inbox: named after the project and only renamed, archived, moved or deleted through it. */ isInbox: boolean; - /** Who besides its owner can see it; only a private board can join a project. Absent reads as private. */ - visibility?: 'private' | 'shared' | 'public'; + /** What the caller may do with a real board, as the server says. Absent for virtual boards. */ + access?: GalleryBoardAccess; +} + +/** A caller's role on a board; each allows everything the ones before it allow. */ +export type GalleryBoardRole = 'viewer' | 'contributor' | 'editor' | 'owner'; + +/** The role every other account gets on a board. */ +export type GalleryGeneralAccess = 'restricted' | 'viewer' | 'contributor'; + +/** What a caller can do to a board as a whole. */ +export type GalleryBoardAction = + | 'read' + | 'add_media' + | 'manage_items' + | 'rename' + | 'set_cover' + | 'archive' + | 'set_general_access' + | 'move' + | 'delete'; + +export interface GalleryBoardAccess { + /** The caller's own role, for display; null when only admin rights give access. Gate on `actions`. */ + role: GalleryBoardRole | null; + /** The board actions the caller may take. An array, not a set, so query caches share it structurally. */ + actions: readonly GalleryBoardAction[]; + /** Admin rights give the caller more than `role` does. */ + adminOverride: boolean; + generalAccess: GalleryGeneralAccess; } export interface GalleryImage extends GeneratedImageContract { diff --git a/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts index 81fd0d2185d..6818e466728 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts @@ -8,7 +8,8 @@ const mocks = vi.hoisted(() => ({ sleep: vi.fn(), })); -vi.mock('@platform/transport/http', () => ({ +vi.mock('@platform/transport/http', async (importOriginal) => ({ + ...(await importOriginal>()), absolutizeApiUrl: (url: string) => `https://api.test${url}`, apiFetch: vi.fn(), apiFetchJson: mocks.apiFetchJson, @@ -16,9 +17,12 @@ vi.mock('@platform/transport/http', () => ({ sleep: mocks.sleep, })); +import { ApiError } from '@platform/transport/http'; + import { addImagesToGalleryBoard, createGalleryBoard, + galleryBoardExists, deleteGalleryBoard, deleteGalleryImages, downloadGalleryArchive, @@ -1059,4 +1063,60 @@ describe('board membership transport', () => { expect(board).toMatchObject({ id: 'b1', isInbox: true, projectId: 'p1' }); }); + + it('reads the caller’s access, and reports none for a server that sends no actions', async () => { + mocks.apiFetchJson.mockResolvedValueOnce({ + ...dto, + admin_override: false, + allowed_actions: ['read', 'add_media'], + general_access: 'contributor', + my_role: 'contributor', + owner_username: 'Ana', + }); + + const shared = await createGalleryBoard('B'); + const unreported = await createGalleryBoard('B'); + + expect(shared).toMatchObject({ + access: { + actions: ['read', 'add_media'], + adminOverride: false, + generalAccess: 'contributor', + role: 'contributor', + }, + ownerName: 'Ana', + }); + expect(unreported.access).toBeUndefined(); + }); +}); + +describe('galleryBoardExists', () => { + beforeEach(() => { + mocks.apiFetchJson.mockReset(); + }); + + it('answers true for a board the server returns, and for one it refuses to show (403): it exists either way', async () => { + mocks.apiFetchJson.mockResolvedValueOnce({ board_id: 'b1' }); + mocks.apiFetchJson.mockRejectedValueOnce( + new ApiError('{"detail":{"code":"board_unreadable","message":"Not authorized to access this board"}}', 403) + ); + + await expect(galleryBoardExists('b1')).resolves.toBe(true); + await expect(galleryBoardExists('restricted')).resolves.toBe(true); + expect(mocks.apiFetchJson).toHaveBeenCalledTimes(2); + }); + + it('answers false only for a board the server says there is none of (404), and rethrows anything else', async () => { + mocks.apiFetchJson.mockRejectedValueOnce(new ApiError('{"detail":"Board not found"}', 404)); + mocks.apiFetchJson.mockRejectedValueOnce(new ApiError('{"detail":"Server error"}', 500)); + + await expect(galleryBoardExists('gone')).resolves.toBe(false); + await expect(galleryBoardExists('b1')).rejects.toBeInstanceOf(ApiError); + }); + + it('takes virtual boards as existing without asking the server', async () => { + await expect(galleryBoardExists('none')).resolves.toBe(true); + await expect(galleryBoardExists('by_date:2026-07-30')).resolves.toBe(true); + expect(mocks.apiFetchJson).not.toHaveBeenCalled(); + }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts index 4e9877f92c4..309c67e2ab0 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts @@ -11,6 +11,7 @@ import type { import type { GallerySemanticQuery } from '@features/gallery/core/semanticImageQuery'; import type { GalleryBoard, + GalleryGeneralAccess, GalleryBoardDeletionResult, GalleryBoardOrderBy, GalleryDeletionResult, @@ -41,29 +42,7 @@ import { import { getGalleryImageThumbnailUrl } from './imageUrls'; import { getGalleryVideoThumbnailUrl } from './videoUrls'; -interface BackendBoardDTO { - board_id: string; - board_name: string; - image_count: number; - asset_count: number; - /** Videos on the board. Counted separately from `image_count` by the backend. */ - video_count?: number; - /** Asset-category (non-'general') videos on the board; uploaded videos are assets. */ - asset_video_count?: number; - archived: boolean; - cover_image_name?: string | null; - /** Set instead of `cover_image_name` when the board's most recent item is a video. */ - cover_video_name?: string | null; - created_at?: string | null; - /** Board owner's display name; populated only for admins on multi-user backends. */ - owner_username?: string | null; - user_id?: string; - board_visibility?: 'private' | 'shared' | 'public'; - /** The project this board belongs to; absent or null for a Library board. */ - project_id?: string | null; - /** Its project's inbox, which only the project routes may rename, archive, move or delete. */ - is_inbox?: boolean; -} +type BackendBoardDTO = components['schemas']['BoardDTO']; /** * 'board' is a real backend board; 'uncategorized' is the pseudo-board for @@ -164,9 +143,10 @@ const toSearchParams = (entries: Record -): string | undefined => { +const getBoardCoverThumbnailUrl = (board: { + cover_image_name?: string | null; + cover_video_name?: string | null; +}): string | undefined => { if (board.cover_image_name) { return getGalleryImageThumbnailUrl(board.cover_image_name); } @@ -187,11 +167,19 @@ const mapBoard = (board: BackendBoardDTO): GalleryBoard => ({ isInbox: board.is_inbox ?? false, kind: 'board', name: board.board_name, - ownerId: board.user_id, ownerName: board.owner_username ?? null, projectId: board.project_id ?? null, - visibility: board.board_visibility, videoCount: board.video_count ?? 0, + ...(board.allowed_actions === undefined + ? {} + : { + access: { + actions: board.allowed_actions, + adminOverride: board.admin_override ?? false, + generalAccess: board.general_access ?? 'restricted', + role: board.my_role ?? null, + }, + }), }); const getGalleryTotal = async ({ @@ -1102,8 +1090,10 @@ export const getGalleryImageMetadata = async ( }; /** - * Whether a board a project names still exists for this account: false only when the server answers that it does - * not. A virtual board (Uncategorized, a date, every board) always exists; any other failure is thrown, unanswered. + * Whether a board a project names still exists: false only when the server answers 404, that there is no such board. + * A board the caller may no longer read answers 403: it exists, so the project keeps naming it, and access granted + * again brings it back. A virtual board (Uncategorized, a date, every board) always exists; any other failure is + * thrown, unanswered. */ export const galleryBoardExists = async (boardId: string, signal?: AbortSignal): Promise => { if (getUploadBoardId(boardId) === undefined) { @@ -1115,8 +1105,8 @@ export const galleryBoardExists = async (boardId: string, signal?: AbortSignal): return true; } catch (error) { - if (error instanceof ApiError && error.status === 404) { - return false; + if (error instanceof ApiError && (error.status === 404 || error.status === 403)) { + return error.status === 403; } throw error; @@ -1138,7 +1128,7 @@ export const createGalleryBoard = async ( /** `projectId` moves the board: a project id, or null for the Library. Undefined, like the others, means unchanged. */ export const updateGalleryBoard = async ( boardId: string, - changes: { name?: string; archived?: boolean; projectId?: string | null }, + changes: { name?: string; archived?: boolean; generalAccess?: GalleryGeneralAccess; projectId?: string | null }, signal?: AbortSignal ): Promise => { const body = await apiFetchJson(`/api/v1/boards/${encodeURIComponent(boardId)}`, { @@ -1146,6 +1136,7 @@ export const updateGalleryBoard = async ( body: JSON.stringify({ archived: changes.archived, board_name: changes.name, + general_access: changes.generalAccess, ...(changes.projectId !== undefined ? { project_id: changes.projectId } : {}), }), method: 'PATCH', diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts index 5e9820ca4dd..c065cca9ae8 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts @@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ALL_READABLE_BOARDS_ID, hydrateGalleryDateBoardItemPage, + listGalleryBoards, listGalleryDateBoardItemNames, listGalleryItems, } from './backend'; @@ -21,9 +22,14 @@ import { type GalleryItemsFilter, } from './queries'; import { + addGalleryBoardToCaches, + canAddToCachedGalleryBoard, + ensureGalleryBoardDestinations, + getCachedGalleryBoard, getGalleryItemBoardIdsFromCaches, getGalleryItemStarredFromCaches, invalidateGallery, + invalidateGalleryBoardDestinations, invalidateGalleryItems, patchGalleryBoardCaches, patchGalleryItemCaches, @@ -32,6 +38,7 @@ import { vi.mock('./backend', async (importOriginal) => ({ ...(await importOriginal>()), hydrateGalleryDateBoardItemPage: vi.fn(), + listGalleryBoards: vi.fn(), listGalleryDateBoardItemNames: vi.fn(), listGalleryItems: vi.fn(), })); @@ -658,6 +665,305 @@ describe('patchGalleryBoardCaches', () => { expect(client.getQueryData(key)?.[1]).toBe(untouched); }); + it('patches general access inside the board’s access, and leaves a virtual board without one alone', () => { + const client = createClient(); + const key = getBoardsKey(); + const access = { + actions: ['read' as const], + adminOverride: false, + generalAccess: 'restricted' as const, + role: 'owner' as const, + }; + client.setQueryData(key, [{ ...createBoard('board-1'), access }, createBoard('none')]); + + patchGalleryBoardCaches(client, 'board-1', { generalAccess: 'contributor' }); + patchGalleryBoardCaches(client, 'none', { generalAccess: 'contributor' }); + + const [patched, virtual] = client.getQueryData(key)!; + expect(patched?.access).toEqual({ ...access, generalAccess: 'contributor' }); + expect(virtual).not.toHaveProperty('access'); + expect(virtual).not.toHaveProperty('generalAccess'); + }); + + it('answers from the newest listing: a view-only or vanished board takes nothing, and with no listing nothing is', () => { + const client = createClient(); + const viewOnly = { + ...createBoard('viewed'), + access: { + actions: ['read' as const], + adminOverride: false, + generalAccess: 'viewer' as const, + role: 'viewer' as const, + }, + }; + + // Nothing is known about any board: none is a destination, so nothing is sent where it may be refused. + expect(canAddToCachedGalleryBoard('anything', client)).toBe(false); + + // An older listing still holds a board the newer one no longer does: revoked since. + vi.useFakeTimers(); + try { + vi.setSystemTime(1_000); + client.setQueryData( + galleryKeys.boards(captureAccountScope(), { + includeArchived: true, + includeDateBoards: false, + orderBy: 'created_at', + orderDir: 'DESC', + }), + [createBoard('revoked'), viewOnly] + ); + vi.setSystemTime(2_000); + client.setQueryData(getBoardsKey(), [viewOnly, createBoard('own')]); + } finally { + vi.useRealTimers(); + } + + expect(canAddToCachedGalleryBoard('own', client)).toBe(true); + expect(canAddToCachedGalleryBoard('viewed', client)).toBe(false); + expect(canAddToCachedGalleryBoard('revoked', client)).toBe(false); + expect(canAddToCachedGalleryBoard('never-listed', client)).toBe(false); + }); + + it('takes an archived board the caller may add to as a destination, and a board the archived listing lacks as gone', () => { + const client = createClient(); + const archivedKey = galleryKeys.boards(captureAccountScope(), { + includeArchived: true, + includeDateBoards: true, + orderBy: 'created_at', + orderDir: 'DESC', + }); + const archivedViewOnly = { + ...createBoard('archived-viewed'), + access: { + actions: ['read' as const], + adminOverride: false, + generalAccess: 'viewer' as const, + role: 'viewer' as const, + }, + archived: true, + }; + vi.useFakeTimers(); + try { + vi.setSystemTime(1_000); + client.setQueryData(archivedKey, [ + { ...createBoard('archived'), archived: true }, + archivedViewOnly, + createBoard('revoked'), + ]); + // The listing of live boards settles last: an archived board is not in it by design. + vi.setSystemTime(2_000); + client.setQueryData(getBoardsKey(), [createBoard('own')]); + } finally { + vi.useRealTimers(); + } + + expect(canAddToCachedGalleryBoard('archived', client)).toBe(true); + expect(canAddToCachedGalleryBoard('archived-viewed', client)).toBe(false); + expect(canAddToCachedGalleryBoard('revoked', client)).toBe(false); + // Only a listing of live boards is cached: an unlisted board may be archived, so it is not a destination until + // the archived listing is ensured, and gone once that listing lacks it. + client.removeQueries({ queryKey: archivedKey }); + expect(canAddToCachedGalleryBoard('archived', client)).toBe(false); + client.setQueryData(archivedKey, [createBoard('own')]); + expect(canAddToCachedGalleryBoard('archived', client)).toBe(false); + }); + + it('keeps deciding from a listing whose refetch failed, which holds its last data under an error status', () => { + const client = createClient(); + const archivedKey = galleryKeys.boards(captureAccountScope(), { + includeArchived: true, + includeDateBoards: true, + orderBy: 'created_at', + orderDir: 'DESC', + }); + client.setQueryData(archivedKey, [{ ...createBoard('archived'), archived: true }, createBoard('own')]); + const listing = client.getQueryCache().find({ queryKey: archivedKey }); + listing?.setState({ error: new Error('offline'), status: 'error' }); + + expect(listing?.state.status).toBe('error'); + expect(canAddToCachedGalleryBoard('archived', client)).toBe(true); + expect(canAddToCachedGalleryBoard('own', client)).toBe(true); + // Absence still counts: the failed listing is the one that would have held a board that exists. + expect(canAddToCachedGalleryBoard('gone', client)).toBe(false); + }); + + const getArchivedBoardsKey = (orderBy: 'board_name' | 'created_at' = 'created_at') => + galleryKeys.boards(captureAccountScope(), { + includeArchived: true, + includeDateBoards: true, + orderBy, + orderDir: orderBy === 'created_at' ? 'DESC' : 'ASC', + }); + const settle = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + it('awaits one read of every readable board, archived included, only while no such listing is cached', async () => { + const client = createClient(); + vi.mocked(listGalleryBoards).mockResolvedValue([ + { ...createBoard('archived'), archived: true }, + createBoard('own'), + ]); + + // A listing of live boards only cannot settle an archived board: the read is awaited. + client.setQueryData(getBoardsKey(), [createBoard('own')]); + const pending = ensureGalleryBoardDestinations(client); + + expect(pending).not.toBeNull(); + await pending; + expect(vi.mocked(listGalleryBoards)).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ includeArchived: true, orderBy: 'created_at', orderDir: 'DESC' }) + ); + expect(canAddToCachedGalleryBoard('archived', client)).toBe(true); + expect(canAddToCachedGalleryBoard('revoked', client)).toBe(false); + + // Read within the minute: nothing to await and nothing read. + expect(ensureGalleryBoardDestinations(client)).toBeNull(); + await settle(); + expect(vi.mocked(listGalleryBoards)).toHaveBeenCalledTimes(1); + }); + + it('decides from a stale listing at once and reads it again in the background', async () => { + const client = createClient(); + client.setQueryData(getArchivedBoardsKey(), [{ ...createBoard('archived'), archived: true }], { + updatedAt: Date.now() - 61_000, + }); + vi.mocked(listGalleryBoards).mockResolvedValue([createBoard('own')]); + + // Synchronous: the stale listing decides this dispatch. + expect(ensureGalleryBoardDestinations(client)).toBeNull(); + expect(canAddToCachedGalleryBoard('archived', client)).toBe(true); + + // One background read, shared by a second resolution made before it lands, serves the next dispatch. + expect(ensureGalleryBoardDestinations(client)).toBeNull(); + await vi.waitFor(() => expect(canAddToCachedGalleryBoard('own', client)).toBe(true)); + expect(vi.mocked(listGalleryBoards)).toHaveBeenCalledTimes(1); + expect(canAddToCachedGalleryBoard('archived', client)).toBe(false); + }); + + it('decides from an invalidated listing at once and reads it again in the background', async () => { + const client = createClient(); + // The panel's listing, in its own order: any listing of archived boards too will do. + const panelKey = getArchivedBoardsKey('board_name'); + client.setQueryData(panelKey, [createBoard('own')], { updatedAt: Date.now() - 1_000 }); + await client.invalidateQueries({ queryKey: panelKey, refetchType: 'none' }); + vi.mocked(listGalleryBoards).mockResolvedValue([createBoard('own'), createBoard('new')]); + + expect(ensureGalleryBoardDestinations(client)).toBeNull(); + expect(canAddToCachedGalleryBoard('own', client)).toBe(true); + expect(canAddToCachedGalleryBoard('new', client)).toBe(false); + + await vi.waitFor(() => expect(canAddToCachedGalleryBoard('new', client)).toBe(true)); + expect(vi.mocked(listGalleryBoards)).toHaveBeenCalledTimes(1); + }); + + it('reads nothing while any listing of archived boards too is fresh, whatever its order', async () => { + const client = createClient(); + client.setQueryData(getArchivedBoardsKey('board_name'), [createBoard('own')]); + + expect(ensureGalleryBoardDestinations(client)).toBeNull(); + await settle(); + expect(vi.mocked(listGalleryBoards)).not.toHaveBeenCalled(); + }); + + it('keeps deciding from a stale listing whose background read fails', async () => { + const client = createClient(); + client.setQueryData(getArchivedBoardsKey(), [createBoard('own')], { updatedAt: Date.now() - 61_000 }); + vi.mocked(listGalleryBoards).mockRejectedValue(new Error('offline')); + + expect(ensureGalleryBoardDestinations(client)).toBeNull(); + await settle(); + + expect(vi.mocked(listGalleryBoards)).toHaveBeenCalledTimes(1); + expect(canAddToCachedGalleryBoard('own', client)).toBe(true); + expect(canAddToCachedGalleryBoard('unlisted', client)).toBe(false); + }); + + it('reads the listing of every readable board again at once after a refusal, with nothing observing it', async () => { + const client = createClient(); + client.setQueryData(getArchivedBoardsKey(), [createBoard('refusing'), createBoard('own')]); + let land!: (boards: GalleryBoard[]) => void; + vi.mocked(listGalleryBoards).mockReturnValue( + new Promise((resolve) => { + land = resolve; + }) + ); + + // Items are left alone: nothing landed. + const itemsKey = [...galleryKeys.itemListsForAccount(captureAccountScope()), 'probe']; + client.setQueryData(itemsKey, { pageParams: [], pages: [] }); + const refusal = invalidateGalleryBoardDestinations(client); + + // A resolution before the read lands decides from the old listing and shares the read. + await vi.waitFor(() => expect(vi.mocked(listGalleryBoards)).toHaveBeenCalledTimes(1)); + expect(ensureGalleryBoardDestinations(client)).toBeNull(); + expect(canAddToCachedGalleryBoard('refusing', client)).toBe(true); + + land([createBoard('own')]); + await refusal; + expect(client.getQueryCache().find({ queryKey: itemsKey })?.state.isInvalidated).toBe(false); + expect(canAddToCachedGalleryBoard('refusing', client)).toBe(false); + // The older listing still describes the refused board, so a refusal can name it; a board no listing holds is + // described by none. + expect(getCachedGalleryBoard('refusing', client)).toEqual(createBoard('refusing')); + expect(getCachedGalleryBoard('own', client)).toEqual(createBoard('own')); + expect(getCachedGalleryBoard('never', client)).toBeUndefined(); + expect(canAddToCachedGalleryBoard('own', client)).toBe(true); + expect(ensureGalleryBoardDestinations(client)).toBeNull(); + await settle(); + expect(vi.mocked(listGalleryBoards)).toHaveBeenCalledTimes(1); + }); + + it('leaves the cached listings to decide when the awaited read fails', async () => { + const client = createClient(); + client.setQueryData(getBoardsKey(), [createBoard('own')]); + vi.mocked(listGalleryBoards).mockRejectedValue(new Error('offline')); + + await expect(ensureGalleryBoardDestinations(client)).resolves.toBeUndefined(); + + expect(canAddToCachedGalleryBoard('own', client)).toBe(true); + expect(canAddToCachedGalleryBoard('unlisted', client)).toBe(false); + }); + + it('lists a board created a moment ago, so it takes results before the refetch', async () => { + const client = createClient(); + client.setQueryData(getBoardsKey(), [createBoard('own')]); + + await addGalleryBoardToCaches(client, createBoard('just-created')); + + expect(canAddToCachedGalleryBoard('just-created', client)).toBe(true); + expect(client.getQueryData(getBoardsKey())?.map((board) => board.id)).toEqual([ + 'own', + 'just-created', + ]); + }); + + it('cancels a listing read that began before the board was created, so it cannot land over it', async () => { + const client = createClient(); + const key = getBoardsKey(); + client.setQueryData(key, [createBoard('own')]); + let settleStaleRead: (boards: GalleryBoard[]) => void = () => undefined; + const staleRead = client + .fetchQuery({ + queryFn: () => + new Promise((resolve) => { + settleStaleRead = resolve; + }), + queryKey: key, + staleTime: 0, + }) + .catch(() => undefined); + + await addGalleryBoardToCaches(client, createBoard('just-created')); + settleStaleRead([createBoard('own')]); + await staleRead; + + expect(client.getQueryData(key)?.map((board) => board.id)).toEqual(['own', 'just-created']); + expect(canAddToCachedGalleryBoard('just-created', client)).toBe(true); + }); + it('does not let rollback clobber a later concurrent boards update', () => { const client = createClient(); const key = getBoardsKey(); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts index 0cd0ab9b0b9..03ded4b1743 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts @@ -5,12 +5,14 @@ import type { GalleryItemRef, GalleryItemsPage, } from '@features/gallery/core/items'; -import type { GalleryBoard } from '@features/gallery/core/types'; +import type { GalleryBoard, GalleryGeneralAccess } from '@features/gallery/core/types'; import type { AccountScope } from '@platform/state/accountLifecycle'; import type { InfiniteData, Query, QueryClient, QueryKey } from '@tanstack/react-query'; +import { canOnBoard } from '@features/gallery/core/boardAccess'; import { toGalleryItemKey } from '@features/gallery/core/items'; import { pruneImageClusterMembers } from '@features/gallery/core/semanticImageQuery'; +import { queryClient } from '@platform/query/client'; import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { rollBackUnclaimedEntries } from '@platform/state/compareAndSwapRollback'; import { hashKey } from '@tanstack/react-query'; @@ -19,6 +21,7 @@ import { ALL_READABLE_BOARDS_ID, isDateBoardId } from './backend'; import { fetchGalleryItemsRange, GALLERY_PAGE_SIZE, + galleryBoardsOptions, galleryKeys, getGalleryItemListingKey, getGalleryItemListQueries, @@ -450,6 +453,153 @@ export const getGalleryProjectBoardsFromCaches = (client: QueryClient, projectId return [...boards.values()]; }; +const isArchivedBoardsListing = (query: Query): boolean => + (query.queryKey.at(-1) as { includeArchived?: boolean } | undefined)?.includeArchived === true; + +/** The account's cached board listings that hold data, newest first. */ +const getCachedGalleryBoardListings = (client: QueryClient): Query[] => + client + .getQueryCache() + .findAll({ queryKey: galleryKeys.boardsForAccount(captureAccountScope()) }) + // A listing whose refetch failed keeps its last data under an error status: it still decides, as the doc of + // `ensureGalleryBoardDestinations` promises, rather than sending every result to the inbox while offline. + .filter((query) => isGalleryBoardsData(query.state.data)) + // Newest first; of two read in the same instant, the one not invalidated since. + .sort( + (left, right) => + right.state.dataUpdatedAt - left.state.dataUpdatedAt || + Number(left.state.isInvalidated) - Number(right.state.isInvalidated) + ); + +/** + * The board as the newest cached listing holding it describes it, or undefined when none does: for saying why + * `canAddToCachedGalleryBoard` refused it (listed without `add_media`, or not listed at all) from the listing that + * decided, not from a panel's older view of the board. + */ +export const getCachedGalleryBoard = (boardId: string, client: QueryClient = queryClient): GalleryBoard | undefined => { + for (const listing of getCachedGalleryBoardListings(client)) { + const board = (listing.state.data as GalleryBoard[]).find((candidate) => candidate.id === boardId); + if (board) { + return board; + } + } + return undefined; +}; + +/** + * Whether the account may add media to a board, as its cached board listings say, newest first. A board listed with + * `add_media` is a destination, archived or not: archiving hides a board from the panel, not from results sent to it + * on purpose. A board no listing holds is not one: it was deleted, stopped being readable, or was never asked about. + * Absence counts only from a listing that would have held the board: one of archived boards too, or a newer listing + * of live boards than one that held it live. A listing counts while it holds data, whatever its status: one whose + * refetch failed keeps the last data it read. The caller decides from a listing it ensured with + * `ensureGalleryBoardDestinations`: nothing is guessed, so a run never fails on its board after generating. + */ +export const canAddToCachedGalleryBoard = (boardId: string, client: QueryClient = queryClient): boolean => { + let isMissingFromNewerLiveListing = false; + + for (const listing of getCachedGalleryBoardListings(client)) { + const board = (listing.state.data as GalleryBoard[]).find((candidate) => candidate.id === boardId); + if (board) { + return !(isMissingFromNewerLiveListing && !board.archived) && canOnBoard(board, 'add_media'); + } + if (isArchivedBoardsListing(listing)) { + return false; + } + isMissingFromNewerLiveListing = true; + } + return false; +}; + +const GALLERY_BOARD_DESTINATIONS_STALE_MS = 60_000; + +/** + * Has a listing for `canAddToCachedGalleryBoard` to decide from before a submission or upload resolves its + * destination: every board the account can read, archived ones included, so an archived board chosen on purpose + * is found and a board missing from it is known to be gone. Returns null, without a tick, while any such listing + * holds data, whatever its age: a caller that must dispatch synchronously decides from it as it is. A listing older + * than a minute, or invalidated after a run or upload failed on its board, is read again in the background, so the + * next resolution decides from fresh data; the read is never awaited and its failure never reported. Only with no + * such listing at all does the caller await one read. A listing that cannot be read leaves the decision to what is + * cached: with nothing cached, no board is a destination and results go to the inbox rather than to a board that + * may refuse them. + */ +export const ensureGalleryBoardDestinations = (client: QueryClient = queryClient): Promise | null => { + const options = galleryBoardsOptions({ includeArchived: true }); + // Any listing of archived boards too will do, whatever its order: an open panel showing archived boards keeps one + // fresh, and then an invoke reads nothing. + const listings = client + .getQueryCache() + .findAll({ queryKey: galleryKeys.boardsForAccount(captureAccountScope()) }) + .filter((query) => isArchivedBoardsListing(query) && isGalleryBoardsData(query.state.data)); + + if (listings.length === 0) { + return client + .fetchQuery({ ...options, staleTime: GALLERY_BOARD_DESTINATIONS_STALE_MS }) + .then(() => undefined) + .catch(() => { + // Unanswered: the cached listings, if any, decide. + }); + } + + const now = Date.now(); + const hasCurrentListing = listings.some( + (query) => !query.state.isInvalidated && now - query.state.dataUpdatedAt < GALLERY_BOARD_DESTINATIONS_STALE_MS + ); + + if (!hasCurrentListing) { + // The read is shared with one already in flight for the same listing and skipped while that listing is fresh. + void client.prefetchQuery({ ...options, staleTime: GALLERY_BOARD_DESTINATIONS_STALE_MS }); + } + + return null; +}; + +/** + * After a board refused a run or an upload, or answered as gone: nothing landed, so only the board listings are + * invalidated, and the listing of every readable board is read again at once rather than marked stale and left, + * since nothing need observe it and `ensureGalleryBoardDestinations` decides from whatever is cached. A resolution + * made before the read lands still decides from the old listing (and shares the read); one made after decides from + * the new one. The read's failure is not reported: the listings cached keep deciding. + */ +export const invalidateGalleryBoardDestinations = async ( + client: QueryClient, + owner: AccountScope = captureAccountScope() +): Promise => { + await client.invalidateQueries({ queryKey: galleryKeys.boardsForAccount(owner) }); + if (!isAccountScopeCurrent(owner)) { + return; + } + // The invalidation refetched any listing of archived boards too that a panel observes; that read serves. + const isReadInFlight = client + .getQueryCache() + .findAll({ queryKey: galleryKeys.boardsForAccount(owner) }) + .some((query) => isArchivedBoardsListing(query) && query.state.fetchStatus === 'fetching'); + if (!isReadInFlight) { + await client.prefetchQuery({ + ...galleryBoardsOptions({ includeArchived: true }), + staleTime: GALLERY_BOARD_DESTINATIONS_STALE_MS, + }); + } +}; + +/** + * Lists a board the account just created in every cached board listing of the account's, ahead of the refetch that + * orders it properly: a board selected as it is created is already where new results go. A listing read still in + * flight was asked before the board existed and would list it away again as it landed, so those reads are cancelled + * first; the refetch that follows the creation reads them again. + */ +export const addGalleryBoardToCaches = async (client: QueryClient, board: GalleryBoard): Promise => { + const owner = captureAccountScope(); + await client.cancelQueries({ queryKey: galleryKeys.boardsForAccount(owner) }); + for (const query of client.getQueryCache().findAll({ queryKey: galleryKeys.boardsForAccount(owner) })) { + const boards = query.state.data; + if (isGalleryBoardsData(boards) && !boards.some((candidate) => candidate.id === board.id)) { + client.setQueryData(query.queryKey, [...boards, board]); + } + } +}; + /** * Patches one board across every cached board list and returns a rollback * that restores the prior lists — skipping any list something else has @@ -458,8 +608,9 @@ export const getGalleryProjectBoardsFromCaches = (client: QueryClient, projectId export const patchGalleryBoardCaches = ( client: QueryClient, boardId: string, - changes: Partial> + changes: Partial> & { generalAccess?: GalleryGeneralAccess } ): (() => void) => { + const { generalAccess, ...fields } = changes; const owner = captureAccountScope(); const rollbackEntries: BoardCacheRollbackEntry[] = []; @@ -477,7 +628,11 @@ export const patchGalleryBoardCaches = ( } changed = true; - return { ...board, ...changes }; + return { + ...board, + ...fields, + ...(generalAccess !== undefined && board.access ? { access: { ...board.access, generalAccess } } : {}), + }; }); if (!changed) { diff --git a/invokeai/frontend/webv2/src/features/gallery/index.ts b/invokeai/frontend/webv2/src/features/gallery/index.ts index c079c029329..066aefeadce 100644 --- a/invokeai/frontend/webv2/src/features/gallery/index.ts +++ b/invokeai/frontend/webv2/src/features/gallery/index.ts @@ -51,7 +51,6 @@ import { getGalleryVideoMetadata, getGalleryVideoWorkflow, isInvalidGalleryBoardDestination, - listGalleryBoards, makeImageCanvasAsset, makeImageDurable, moveGalleryVideoItemsToBoard, @@ -247,11 +246,6 @@ export const galleryItemOrganization = { ), } as const; -/** Destination choices for callers such as Workflow fields. */ -export const galleryDestinations = { - list: listGalleryBoards, -} as const; - /** Virtual boards and none cannot receive attachments; organization transports no-op for these destinations. */ export const isGalleryBoardAttachable = (boardId: string): boolean => boardId !== 'none' && !isInvalidGalleryBoardDestination(boardId); diff --git a/invokeai/frontend/webv2/src/features/gallery/picker.ts b/invokeai/frontend/webv2/src/features/gallery/picker.ts index e3a9745b6f1..230b56d73d6 100644 --- a/invokeai/frontend/webv2/src/features/gallery/picker.ts +++ b/invokeai/frontend/webv2/src/features/gallery/picker.ts @@ -7,3 +7,4 @@ export { } from './ui/picker/galleryPicker'; export { GalleryPickerPopover, type GalleryPickerPopoverProps } from './ui/picker/GalleryPickerPopover'; export { GalleryHostProvider, type GalleryHost } from './ui/GalleryUiContext'; +export { useGalleryUploadAction } from './ui/useGalleryUploadAction'; diff --git a/invokeai/frontend/webv2/src/features/gallery/queries.ts b/invokeai/frontend/webv2/src/features/gallery/queries.ts index 1898036d9c3..1c01c22655b 100644 --- a/invokeai/frontend/webv2/src/features/gallery/queries.ts +++ b/invokeai/frontend/webv2/src/features/gallery/queries.ts @@ -19,6 +19,7 @@ export { imageIndexAvailabilityOptions, } from './data/queries'; export { abortGalleryLocatorRequests, createGalleryLocatorRequest } from './core/selection'; +export { canAddToCachedGalleryBoard, ensureGalleryBoardDestinations, getCachedGalleryBoard } from './data/queryCache'; export type { CanonicalGalleryItemsFilter, GalleryBoardsQuery, @@ -34,6 +35,7 @@ export { getGalleryProjectBoardsFromCaches, getGalleryItemStarredFromCaches, invalidateGallery, + invalidateGalleryBoardDestinations, invalidateGalleryItems, patchGalleryItemCaches, getGalleryThumbnailRevision, diff --git a/invokeai/frontend/webv2/src/features/gallery/react.test.ts b/invokeai/frontend/webv2/src/features/gallery/react.test.ts new file mode 100644 index 00000000000..4103bdcd51e --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/react.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +const sources = import.meta.glob('./react.ts', { eager: true, import: 'default', query: '?raw' }) as Record< + string, + string +>; + +/** + * The React entry is bundled with the eager gallery-state chunk. A re-export from a lazy gallery module (uploads, + * the picker) would make the state chunk and the picker chunk import each other, and the picker's module-scope code + * would run before the state chunk initialised: the production editor failed to start that way once. Dev and test + * module orders differ, so only the entry's import surface can be checked cheaply. + */ +describe('gallery React entry', () => { + it('reaches only the eager UI port and core types', () => { + const source = sources['./react.ts']; + const specifiers = [...(source ?? '').matchAll(/from '([^']+)'/g)].map(([, specifier]) => specifier); + + expect(source).toBeDefined(); + expect(new Set(specifiers)).toEqual(new Set(['./ui/GalleryUiContext', './core/types'])); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/react.ts b/invokeai/frontend/webv2/src/features/gallery/react.ts index 0b342973de4..a65393dc880 100644 --- a/invokeai/frontend/webv2/src/features/gallery/react.ts +++ b/invokeai/frontend/webv2/src/features/gallery/react.ts @@ -7,6 +7,9 @@ export { type GalleryItemContextMenuProps, type GalleryItemContextMenuTarget, type GalleryUiAdapter, + useHostGalleryBoards, } from './ui/GalleryUiContext'; export type { GalleryProjectRef } from './core/types'; -export { useGalleryUploadAction } from './ui/useGalleryUploadAction'; +// Only the eager UI port lives here. This entry is bundled with the eager gallery-state chunk; reaching a lazy +// gallery module (uploads, the picker) from it makes that chunk and the picker chunk import each other, and the +// picker's module-scope code then runs before the state chunk has initialised. Uploads are published by `picker`. diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.browser.test.tsx index 1b3ebf37b3b..9ff9aaba841 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.browser.test.tsx @@ -1,5 +1,5 @@ /* oxlint-disable react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ -import type { GalleryBoard } from '@features/gallery/core/types'; +import type { GalleryBoard, GalleryBoardAccess, GalleryBoardAction } from '@features/gallery/core/types'; import { ChakraProvider } from '@chakra-ui/react'; import { system } from '@theme/system'; @@ -60,14 +60,14 @@ const context = { downloadBoard: vi.fn(), moveBoard: vi.fn(), renameBoard: vi.fn(), + setBoardGeneralAccess: vi.fn(), updateSettings: vi.fn(), }, gallery: { + boards: [board], projectBoardId: null, settings: { autoAddBoardId: 'follow' }, }, - // Signed in, as an admin can be, alongside boards that are not theirs. - currentUserId: 'me', projectId: 'p1', projectName: 'Mahogany House', projectNames: new Map([ @@ -156,10 +156,15 @@ it('hands results back to the selected board from the auto-add board itself', as }); const renderMenuFor = async (menuBoard: GalleryBoard, overrides: Partial = {}) => { + const value = { + ...context, + gallery: { ...context.gallery, boards: [menuBoard] }, + ...overrides, + } as GalleryWidgetContextValue; await act(async () => { root?.render( - + @@ -211,31 +216,113 @@ it('offers a Library board the open project first', async () => { ).toEqual(['Mahogany House', 'Harbor Tower']); }); -it.each([ - ['someone else’s board, which only its owner can move into their projects', { ownerId: 'them' }], - ['a shared or public board, which stays in the Library', { ownerId: 'me', visibility: 'shared' as const }], -])('offers no move for %s, while keeping the rest of its menu', async (_label, overrides) => { - await renderMenuFor({ ...board, ...overrides } as GalleryBoard); +it('leaves an inbox with only the actions its project does not own', async () => { + await renderMenuFor({ ...board, isInbox: true, projectId: 'p1' } as GalleryBoard); const labels = menuItemLabels(); + expect(labels).toContain('widgets.gallery.exportProjectFromBoard'); expect(labels).not.toContain('Move to…'); - expect(labels).toContain('widgets.gallery.renameBoard'); + expect(labels).not.toContain('Delete Board'); + expect(labels).not.toContain('widgets.gallery.renameBoard'); +}); + +const withAccess = ( + role: GalleryBoardAccess['role'], + actions: GalleryBoardAction[], + adminOverride = false +): GalleryBoard => + ({ + ...board, + access: { actions, adminOverride, generalAccess: 'restricted', role }, + ownerName: 'Ana', + }) as GalleryBoard; +const ALL_ACTIONS: GalleryBoardAction[] = [ + 'read', + 'add_media', + 'manage_items', + 'rename', + 'set_cover', + 'archive', + 'set_general_access', + 'move', + 'delete', +]; + +it('offers a viewer only what they may do, and says whose board it is and whom to ask', async () => { + await renderMenuFor(withAccess('viewer', ['read']), { boardSharing: true }); + + expect(menuItemLabels()).toEqual(['Download Board (1 video omitted)']); + expect(document.body.textContent).toContain('widgets.gallery.boardAccess.header.viewer'); + expect(document.body.textContent).toContain('widgets.gallery.boardAccess.askToAdd'); }); -it('offers the move on a single-user install, where every board is the one user’s whatever owner it records', async () => { - await renderMenuFor({ ...board, ownerId: 'system' } as GalleryBoard, { currentUserId: null }); +it('lets an editor rename and archive someone else’s board, but not move, share or delete it', async () => { + await renderMenuFor(withAccess('editor', ['read', 'add_media', 'manage_items', 'rename', 'set_cover', 'archive']), { + boardSharing: true, + }); - expect(menuItemLabels()).toContain('Move to…'); + expect(menuItemLabels()).toEqual([ + 'widgets.gallery.autoAddToBoard', + 'Download Board (1 video omitted)', + 'widgets.gallery.renameBoard', + 'widgets.gallery.archiveBoard', + ]); + expect(document.body.textContent).toContain('widgets.gallery.boardAccess.header.editor'); + expect(document.body.textContent).not.toContain('askToAdd'); }); -it('leaves an inbox with only the actions its project does not own', async () => { - await renderMenuFor({ ...board, isInbox: true, projectId: 'p1' } as GalleryBoard); +it('lets an owner change who can open the board, only where boards can be shared', async () => { + await renderMenuFor(withAccess('owner', ALL_ACTIONS), { boardSharing: false }); + expect(menuItemLabels().some((label) => label?.includes('widgets.gallery.boardAccess.menu'))).toBe(false); + expect(document.body.textContent).not.toContain('boardAccess.header'); - const labels = menuItemLabels(); + await renderMenuFor(withAccess('owner', ALL_ACTIONS), { boardSharing: true }); + const access = Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => + item.textContent?.includes('widgets.gallery.boardAccess.menu') + )!; + await act(() => userEvent.click(access)); + const everyone = await vi.waitFor(() => { + const option = document.querySelector('[role="menuitemradio"][data-value="viewer"]'); + expect(option).not.toBeNull(); + return option!; + }); + await act(() => userEvent.click(everyone)); - expect(labels).toContain('widgets.gallery.exportProjectFromBoard'); + expect(context.actions.setBoardGeneralAccess).toHaveBeenCalledExactlyOnceWith('board-1', 'viewer'); +}); + +it('gives an admin someone else’s board to manage, but no move into their own projects', async () => { + await renderMenuFor(withAccess(null, ALL_ACTIONS, true), { boardSharing: true }); + + const labels = menuItemLabels(); + expect(document.body.textContent).toContain('widgets.gallery.boardAccess.header.admin'); + expect(labels).toContain('Delete Board'); expect(labels).not.toContain('Move to…'); - expect(labels).not.toContain('Delete Board'); - expect(labels).not.toContain('widgets.gallery.renameBoard'); +}); + +it('closes the rename dialog once the board no longer allows renaming', async () => { + const editable = withAccess('editor', ['read', 'rename']); + await renderMenuFor(editable); + const rename = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent === 'widgets.gallery.renameBoard' + )!; + await act(() => userEvent.click(rename)); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull()); + + // A refetch reports the role revoked to viewer. + await renderMenuFor(editable, { + gallery: { ...context.gallery, boards: [withAccess('viewer', ['read'])] } as GalleryWidgetContextValue['gallery'], + }); + + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); + + // The role comes back on a later refetch: the dialog stays closed rather than reopening with what was typed. + await renderMenuFor(editable, { + gallery: { ...context.gallery, boards: [editable] } as GalleryWidgetContextValue['gallery'], + }); + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.tsx index 30d44ea18ce..0004f30fb68 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.tsx @@ -1,6 +1,7 @@ -import type { GalleryBoard } from '@features/gallery/core/types'; +import type { GalleryBoard, GalleryBoardAction, GalleryGeneralAccess } from '@features/gallery/core/types'; import { HStack, Icon, Input, Menu, Portal, Stack, Text } from '@chakra-ui/react'; +import { canOnBoard, isOwnGalleryBoard } from '@features/gallery/core/boardAccess'; import { GALLERY_AUTO_ADD_FOLLOW } from '@features/gallery/core/settings'; import { Button } from '@platform/ui/Button'; import { Dialog } from '@platform/ui/Dialog'; @@ -15,12 +16,13 @@ import { PencilIcon, SquareArrowDownIcon, Trash2Icon, + UsersIcon, type LucideIcon, } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { canMoveGalleryBoard, isManagedGalleryBoard } from './galleryBoardGroups'; +import { isManagedGalleryBoard, isMovableGalleryBoard } from './galleryBoardGroups'; import { useGalleryWidget } from './GalleryWidgetContext'; export interface GalleryBoardMenuTarget { @@ -41,17 +43,33 @@ export const GalleryBoardMenu = ({ onClose: () => void; }) => { const { t } = useTranslation(); - const { actions, currentUserId, gallery } = useGalleryWidget(); + const { actions, boardSharing = false, gallery } = useGalleryWidget(); const [renameTarget, setRenameTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [renameValue, setRenameValue] = useState(''); + const accessHeaderId = useId(); const targetRef = useRef(target); // eslint-disable-next-line react/refs targetRef.current = target; const board = target?.board ?? null; + // Other than an inbox, a real board is managed as far as the server allows the caller. const isManagedBoard = board !== null && isManagedGalleryBoard(board, gallery.projectBoardId); + const can = (action: GalleryBoardAction) => board !== null && isManagedBoard && canOnBoard(board, action); + // A dialog stays open only while the board is listed and still allows its action: a refetch after a revocation + // or a deletion elsewhere closes it, for good, rather than leaving a change that would only be refused or reopening + // it with what was typed when access returns. + const isStillAllowed = (dialogBoard: GalleryBoard, action: GalleryBoardAction) => { + const listed = gallery.boards.find((candidate) => candidate.id === dialogBoard.id); + return Boolean(listed && canOnBoard(listed, action)); + }; + if (renameTarget !== null && !isStillAllowed(renameTarget, 'rename')) { + setRenameTarget(null); + } + if (deleteTarget !== null && !isStillAllowed(deleteTarget, 'delete')) { + setDeleteTarget(null); + } const positioning = useMemo( () => ({ getAnchorRect: () => { @@ -141,8 +159,14 @@ export const GalleryBoardMenu = ({ {board && ( - - {board.isInbox && board.projectId !== null && ( + + + {board.isInbox && board.projectId !== null && isOwnGalleryBoard(board) && ( <> @@ -150,17 +174,22 @@ export const GalleryBoardMenu = ({ )} {board.id === gallery.settings.autoAddBoardId ? ( - ) : board.kind !== 'date' && !board.archived ? ( + ) : board.kind !== 'date' && !board.archived && canOnBoard(board, 'add_media') ? ( ) : null} - {isManagedBoard && ( + {can('rename') && ( + + )} + {boardSharing && can('set_general_access') && } + {isMovableGalleryBoard(board, gallery.projectBoardId) && ( + + )} + {can('archive') && ( + + )} + {can('delete') && ( <> - - {canMoveGalleryBoard(board, gallery.projectBoardId, currentUserId) && ( - - )} - @@ -479,3 +508,122 @@ const BoardMenuItem = ({ ); + +/** + * Whose board someone else's board is and what the caller may do with it, above its actions: the menu offers only + * what is allowed, so this says why the rest is missing and whom to ask. Nothing on the caller's own boards. + */ +const hasAccessHeader = (board: GalleryBoard) => board.access !== undefined && board.access.role !== 'owner'; + +const BoardAccessHeader = ({ board, id }: { board: GalleryBoard; id: string }) => { + const { t } = useTranslation(); + const { access } = board; + + if (access === undefined || !hasAccessHeader(board)) { + return null; + } + + const owner = board.ownerName ?? t('widgets.gallery.boardAccess.deletedOwner'); + const level = access.adminOverride || access.role === null ? 'admin' : access.role; + + return ( + + + + {t(`widgets.gallery.boardAccess.header.${level}`, { owner })} + {level === 'viewer' ? {t('widgets.gallery.boardAccess.askToAdd', { owner })} : null} + + + + + ); +}; + +const GENERAL_ACCESS_OPTIONS: readonly GalleryGeneralAccess[] = ['restricted', 'viewer', 'contributor']; + +/** Who else may open the board, for its owner and admins: applied at once, and reported. */ +const BoardGeneralAccessSubMenu = ({ board }: { board: GalleryBoard }) => { + const { t } = useTranslation(); + const { actions } = useGalleryWidget(); + const current = board.access?.generalAccess ?? 'restricted'; + const handleValueChange = useCallback( + ({ value }: { value: string }) => { + if (value !== current && GENERAL_ACCESS_OPTIONS.includes(value as GalleryGeneralAccess)) { + void actions.setBoardGeneralAccess(board.id, value as GalleryGeneralAccess); + } + }, + [actions, board.id, current] + ); + const describe = (option: GalleryGeneralAccess) => + option !== 'restricted' + ? t(`widgets.gallery.boardAccess.general.${option}Description`) + : isOwnGalleryBoard(board) + ? t('widgets.gallery.boardAccess.general.restrictedDescription') + : t('widgets.gallery.boardAccess.general.restrictedDescriptionOther', { + owner: board.ownerName ?? t('widgets.gallery.boardAccess.deletedOwner'), + }); + + return ( + + + + + + {t('widgets.gallery.boardAccess.menu')} + + + {t(`widgets.gallery.boardAccess.general.${current}`)} + + + + + + + + + + {t('widgets.gallery.boardAccess.general.label')} + + {GENERAL_ACCESS_OPTIONS.map((option) => ( + + ))} + + + + + + ); +}; + +/** One choice, named by its label alone and described by its consequence, so each is announced distinctly. */ +const GeneralAccessOption = ({ + description, + label, + value, +}: { + description: string; + label: string; + value: GalleryGeneralAccess; +}) => { + const labelId = useId(); + const descriptionId = useId(); + + return ( + + + + + {label} + + + {description} + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardRow.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardRow.tsx index 13601791166..4f25aa0e707 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardRow.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardRow.tsx @@ -15,6 +15,7 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { BoardCover } from './GalleryBoardCover'; +import { getGalleryBoardOwnerLabel } from './galleryBoardLabels'; import { GalleryBoardRowShell } from './GalleryBoardRowShell'; import { acceptsGalleryItemMoves, @@ -29,6 +30,7 @@ import { getBoardCounts } from './galleryStateView'; export const GalleryBoardRow = ({ accessibleName, board, + autoAddRedirectedFrom, dragScope, isAutoAddTarget = false, isMenuOpen, @@ -48,6 +50,8 @@ export const GalleryBoardRow = ({ dragScope?: string; /** Results without a board of their own land here (the gallery is not following its selection). */ isAutoAddTarget?: boolean; + /** The view-only board whose results come here instead, named in the Auto badge's tooltip. */ + autoAddRedirectedFrom?: string; /** Its own menu is showing, so the trigger must not fade out from under it. */ isMenuOpen?: boolean; isSelected: boolean; @@ -65,7 +69,7 @@ export const GalleryBoardRow = ({ const spokenLabel = accessibleName ?? boardLabel; const canDropItems = - acceptsGalleryItemMoves(board.kind) && + acceptsGalleryItemMoves(board) && isGalleryItemDragData(dragData) && dragData.items.some((ref) => loadedItemBoardIds.get(toGalleryItemKey(ref)) !== board.id); @@ -149,18 +153,19 @@ export const GalleryBoardRow = ({ const stopPropagation = useCallback((event: MouseEvent | PointerEvent) => event.stopPropagation(), []); const cover = useMemo(() => , [board]); + const ownerLabel = getGalleryBoardOwnerLabel(board, t); const subtitle = useMemo( () => - board.ownerName ? ( + ownerLabel ? ( ) : null, - [board.ownerName, isSelected] + [ownerLabel, isSelected] ); const actions = useMemo( () => @@ -249,7 +254,13 @@ export const GalleryBoardRow = ({ onSelect={handleSelect} > {isAutoAddTarget ? ( - + {t('widgets.gallery.autoAddBadge')} diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardSection.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardSection.tsx index 047378256fb..7d91116b23c 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardSection.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardSection.tsx @@ -36,7 +36,8 @@ export const GalleryBoardSection = ({ ); return ( - + // A group that starts closed (an admin's view of other users' boards) mounts its rows only once opened. + { ); }; -const renderPanel = async ( - gallery: GalleryStateView = createGallery(), - { currentUserId = null, withDrag = false }: { currentUserId?: string | null; withDrag?: boolean } = {} -) => { +const renderPanel = async (gallery: GalleryStateView = createGallery(), { withDrag = false } = {}) => { const contextValue = { actions, boardsState: { error: null, isRetrying: false, retry: () => Promise.resolve(), status: 'ready' }, - currentUserId, gallery, itemActions, projectId: 'p1', @@ -420,29 +416,42 @@ describe('GalleryBoardsPanel', () => { expect(actions.moveBoard).not.toHaveBeenCalled(); }); - it('drags only boards the server would move, and only onto the caller’s own projects', async () => { - // An admin sees other people's boards and projects; a shared board of their own stays in the Library. + it("never drags someone else's board onto the caller's own tiers", async () => { + const shared = createBoard({ + access: { + actions: ['read', 'add_media', 'rename'], + adminOverride: false, + generalAccess: 'viewer', + role: 'editor', + }, + id: 'refs', + name: 'Client references', + ownerName: 'Cleo', + }); + // An admin's rights allow the move, but its destinations would be the admin's projects, not the owner's. + const adminOnly = createBoard({ + access: { + actions: ['read', 'add_media', 'rename', 'move'], + adminOverride: true, + generalAccess: 'restricted', + role: null, + }, + id: 'sketches', + name: 'Private sketches', + ownerName: 'Dee', + }); await renderPanel( { - ...createGallery({ showOtherProjectBoards: true }), - boards: boards.map((board) => - board.projectId === 'p2' || board.id === 'cats' - ? { ...board, ownerId: 'them' } - : board.id === 'dogs' - ? { ...board, ownerId: 'me', visibility: 'shared' as const } - : { ...board, ownerId: 'me' } - ), - } as GalleryStateView, - { currentUserId: 'me', withDrag: true } + ...createGallery({ expandedBoardSections: ['other-users'], showOtherProjectBoards: true }), + boards: [...boards, shared, adminOnly], + }, + { withDrag: true } ); - await dragWithMouse(row('Cats'), heading('Mahogany House')); - await dragWithMouse(row('dogs'), heading('Mahogany House')); - await dragWithMouse(row('Façades'), heading('Harbor Tower')); - expect(actions.moveBoard).not.toHaveBeenCalled(); + await dragWithMouse(row('Client references'), heading('Harbor Tower')); + await dragWithMouse(row('Private sketches'), heading('Harbor Tower')); - await dragWithMouse(row('Façades'), heading('Library')); - expect(actions.moveBoard).toHaveBeenCalledExactlyOnceWith('mine-member', null, 'Library'); + expect(actions.moveBoard).not.toHaveBeenCalled(); }); it('takes a drop on a collapsed tier, by its heading', async () => { @@ -619,6 +628,50 @@ describe('GalleryBoardsPanel', () => { expect(actions.createBoard).toHaveBeenCalledWith('birds', 'p1'); }); + it('lists other people’s boards apart, an admin’s view of them closed until opened', async () => { + const shared = createBoard({ + access: { actions: ['read'], adminOverride: false, generalAccess: 'viewer', role: 'viewer' }, + id: 'refs', + name: 'Client references', + ownerName: 'Cleo', + }); + const adminOnly = createBoard({ + access: { actions: ['read', 'add_media'], adminOverride: true, generalAccess: 'restricted', role: null }, + id: 'sketches', + name: 'Private sketches', + ownerName: 'Dee', + }); + await renderPanel({ ...createGallery(), boards: [...boards, shared, adminOnly] }); + + const sections = getRowsBySection(); + expect(sections['widgets.gallery.boardGroups.shared']).toEqual([ + 'Client referenceswidgets.gallery.boardAccess.ownerAndAccess', + ]); + expect(sections.Library).not.toContain('Client references'); + const otherUsers = Array.from( + host?.querySelectorAll('[data-scope="collapsible"][data-part="trigger"]') ?? [] + ).find((trigger) => trigger.textContent?.startsWith('widgets.gallery.boardGroups.otherUsers')); + expect(otherUsers?.getAttribute('aria-expanded')).toBe('false'); + + await click(otherUsers!); + expect(actions.updateSettings).toHaveBeenCalledWith({ expandedBoardSections: ['other-users'] }); + }); + + it('marks the inbox Auto when the selected board is one the caller may only view', async () => { + const shared = createBoard({ + access: { actions: ['read'], adminOverride: false, generalAccess: 'viewer', role: 'viewer' }, + id: 'refs', + name: 'Client references', + ownerName: 'Cleo', + }); + await renderPanel({ ...createGallery(), boards: [...boards, shared], selectedBoardId: 'refs' }); + + const inboxRow = getBoardRows().find((row) => row.textContent?.startsWith('Inbox')); + const sharedRow = getBoardRows().find((row) => row.textContent?.startsWith('Client references')); + expect(inboxRow?.textContent).toContain('widgets.gallery.autoAddBadge'); + expect(sharedRow?.textContent).not.toContain('widgets.gallery.autoAddBadge'); + }); + it('persists a collapsed section', async () => { await renderPanel(); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.tsx index 85988c4d29e..1f93558bfba 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.tsx @@ -4,7 +4,9 @@ import type { GalleryBoard } from '@features/gallery/core/types'; import { Box, HStack, Icon, ScrollArea, Stack, Text } from '@chakra-ui/react'; import { useDndContext, useDroppable } from '@dnd-kit/core'; +import { getGalleryBoardLabel } from '@features/gallery/core/boardLabels'; import { toGalleryItemKey } from '@features/gallery/core/items'; +import { isGalleryBoardSectionOpen, toggleGalleryBoardSection } from '@features/gallery/core/settings'; import { usePreservedScrollOffset } from '@platform/react/usePreservedScrollOffset'; import { IconButton } from '@platform/ui/Button'; import { RenameDialog } from '@platform/ui/RenameDialog'; @@ -15,7 +17,7 @@ import { useTranslation } from 'react-i18next'; import { BoardCoverIcon } from './GalleryBoardCover'; import { GalleryBoardFilters } from './GalleryBoardFilters'; -import { canMoveGalleryBoard, getGalleryBoardGroups, isOwnGalleryBoard } from './galleryBoardGroups'; +import { getGalleryBoardGroups, isMovableGalleryBoard } from './galleryBoardGroups'; import { GalleryBoardMenu, type GalleryBoardMenuTarget } from './GalleryBoardMenu'; import { GalleryBoardRow } from './GalleryBoardRow'; import { GalleryBoardRowShell } from './GalleryBoardRowShell'; @@ -27,6 +29,7 @@ import { getGalleryBoardTierDropId, } from './galleryDnd'; import { focusVisibleOperable, GalleryLoadNotice } from './GalleryLoadError'; +import { getGalleryRedirectedDestination } from './galleryStateView'; import { useGalleryWidget } from './GalleryWidgetContext'; const SCROLL_CONTENT_PROPS = { py: '1' } as const; @@ -37,8 +40,7 @@ type CreateTier = 'library' | 'project'; export const GalleryBoardsPanel = () => { const { t } = useTranslation(); - const { actions, boardsState, currentUserId, gallery, projectId, projectName, projectNames, region } = - useGalleryWidget(); + const { actions, boardsState, gallery, projectId, projectName, projectNames, region } = useGalleryWidget(); // Boards move between tiers by dragging only inside the workbench's drag context; elsewhere, by their menu. const canDragBoards = use(GalleryDragScope); const [searchTerm, setSearchTerm] = useState(''); @@ -51,7 +53,7 @@ export const GalleryBoardsPanel = () => { // container that stops being rendered loses its offset outright. usePreservedScrollOffset(boardsViewportRef); - const { collapsedBoardSections, showArchivedBoards, showDateBoards, showOtherProjectBoards } = gallery.settings; + const { showArchivedBoards, showDateBoards, showOtherProjectBoards } = gallery.settings; const groups = useMemo( () => @@ -153,17 +155,12 @@ export const GalleryBoardsPanel = () => { }, []); const handleToggleSection = useCallback( - (sectionId: GalleryBoardSectionId, isOpen: boolean) => { - const nextSections = isOpen - ? collapsedBoardSections.filter((entry) => entry !== sectionId) - : [...collapsedBoardSections, sectionId]; - - actions.updateSettings({ collapsedBoardSections: nextSections }); - }, - [actions, collapsedBoardSections] + (sectionId: GalleryBoardSectionId, isOpen: boolean) => + actions.updateSettings(toggleGalleryBoardSection(gallery.settings, sectionId, isOpen)), + [actions, gallery.settings] ); - const isSectionOpen = (sectionId: GalleryBoardSectionId) => !collapsedBoardSections.includes(sectionId); + const isSectionOpen = (sectionId: GalleryBoardSectionId) => isGalleryBoardSectionOpen(gallery.settings, sectionId); // Without the backend's list, the placeholder Uncategorized row (and "no matches") would claim there is nothing // else; the failure takes the rows' place instead. While it loads, the fixed rows alone would read as empty tiers. const isBoardListUnavailable = boardsState.status === 'error'; @@ -200,15 +197,18 @@ export const GalleryBoardsPanel = () => { } }, [focusBoardRow]); + // Results the selected or auto-add board refuses (someone else's, view-only) go to the inbox, and its row says so. + const redirected = getGalleryRedirectedDestination(gallery); const renderRow = (board: GalleryBoard, accessibleName?: string) => ( { onSelect={tier === 'project' ? createProjectBoardFromSearch : createLibraryBoardFromSearch} /> ) : null; - // A dragged board moves to whichever tier it is dropped on, so long as the tier is the caller's: an admin also sees - // other people's projects, which only their owners' boards can join. - const renderDropTier = (tierProjectId: string | null, label: string, content: ReactNode, isOwnTier = true) => - canDragBoards && isOwnTier ? ( + // A dragged board moves to whichever tier it is dropped on. Every tier is the caller's own: other people's boards are + // listed apart, never under a project of theirs. + const renderDropTier = (tierProjectId: string | null, label: string, content: ReactNode) => + canDragBoards ? ( {content} @@ -308,6 +308,30 @@ export const GalleryBoardsPanel = () => { )} + {!isBoardListUnavailable && groups.sharedBoards.length > 0 ? ( + + {groups.sharedBoards.map((board) => renderRow(board))} + + ) : null} + + {!isBoardListUnavailable && groups.otherUsersBoards.length > 0 ? ( + + {groups.otherUsersBoards.map((board) => renderRow(board))} + + ) : null} + {!isBoardListUnavailable && groups.otherProjects.length > 0 ? ( { board.isInbox ? t('widgets.gallery.inboxOf', { project: group.label }) : undefined ) )} - , - group.boards.every((board) => isOwnGalleryBoard(board, currentUserId)) + )} ))} diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx index 442cc675aaa..3a406704497 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx @@ -9,6 +9,7 @@ import type { } from '@features/gallery/core/types'; import type { QueueProgressSession } from '@features/queue/contracts'; +import { asSingleAccountBoards } from '@features/gallery/core/boardAccess'; import { createContext, use, useMemo, type ComponentType, type ReactNode } from 'react'; export interface GalleryItemActions { @@ -107,14 +108,14 @@ export interface GalleryUiAdapter { ItemActionsProvider: ComponentType; ImageContextMenu: ComponentType; antialiasProgressImages: boolean; + /** The server has more than one account, so boards can be shared. Absent (off) for hosts without one. */ + boardSharing?: boolean; gallery: GalleryCommandsPort; galleryValues: Record; generateValues: Record; /** Resolves an item's best image-map vocabulary label, or null when it has none. */ getItemLabel(item: GalleryItemRef): Promise; notifications: GalleryNotificationsPort; - /** The signed-in user, whose boards alone can move into or between their projects; null on a single-user install. */ - currentUserId?: string | null; projectId: string; projectName: string; /** The account's projects, for naming the boards that belong to each; the open project need not be among them. */ @@ -181,6 +182,29 @@ export const useGalleryUi = (): GalleryUiAdapter => { /** For hooks shared with hosts that have no workbench; they use it only for Gallery widget side effects. */ export const useOptionalGalleryUi = (): GalleryUiAdapter | null => use(GalleryUiContext); +const NO_BOARDS: GalleryBoard[] = []; + +/** + * A board listing as this host shows it. Only a host that says boards cannot be shared (a server with one account) + * folds them into the caller's own; every surface that names boards (the panel, board menus, workflow board fields) + * reads the listing through here, so one board never reads as the caller's in one place and as someone else's in + * another. Undefined (a listing not loaded yet) reads as no boards, with a stable identity. + */ +export const useHostGalleryBoards = (boards: readonly GalleryBoard[] | undefined): GalleryBoard[] => { + const isSingleAccount = useOptionalGalleryUi()?.boardSharing === false; + + return useMemo( + () => + boards === undefined + ? NO_BOARDS + : isSingleAccount + ? asSingleAccountBoards(boards) + : // The listing's own identity: a folded list is a new one only when folding changes a board. + (boards as GalleryBoard[]), + [boards, isSingleAccount] + ); +}; + /** * What gallery surfaces outside the Gallery widget (the picker, media slots) need from their host. The workbench * derives it from its UI adapter; a host without a workbench (the Launchpad) supplies one with GalleryHostProvider. diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUploadButton.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUploadButton.test.ts index 9ff5d9fa977..75fd18436e8 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUploadButton.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUploadButton.test.ts @@ -42,4 +42,16 @@ describe('getGalleryUploadTargetLabel', () => { label: 'widgets.gallery.uploadsUnavailableForDateBoards', }); }); + + it('marks a board the caller may only view unavailable, and says why', () => { + const viewOnly: GalleryBoard = { + ...board, + access: { actions: ['read'], adminOverride: false, generalAccess: 'viewer', role: 'viewer' }, + }; + + expect(getGalleryUploadTargetLabel([viewOnly], 'dogs', t)).toEqual({ + isAvailable: false, + label: 'widgets.gallery.uploadsUnavailableForViewOnlyBoard:Dogs', + }); + }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUploadButton.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUploadButton.tsx index b1b0aa070d2..a71afcf9758 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUploadButton.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUploadButton.tsx @@ -2,6 +2,7 @@ import type { GalleryBoard } from '@features/gallery/core/types'; import type { TFunction } from 'i18next'; import { Icon } from '@chakra-ui/react'; +import { canOnBoard } from '@features/gallery/core/boardAccess'; import { getGalleryBoardLabel } from '@features/gallery/core/boardLabels'; import { isDateBoardId } from '@features/gallery/data/backend'; import { IconButton } from '@platform/ui/Button'; @@ -23,6 +24,13 @@ export const getGalleryUploadTargetLabel = ( const selectedBoard = boards.find((board) => board.id === selectedBoardId); + if (selectedBoard && !canOnBoard(selectedBoard, 'add_media')) { + return { + isAvailable: false, + label: t('widgets.gallery.uploadsUnavailableForViewOnlyBoard', { name: getGalleryBoardLabel(selectedBoard, t) }), + }; + } + return { isAvailable: true, label: t('widgets.gallery.uploadMediaToBoard', { diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetContext.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetContext.tsx index 7e48f315cd7..e87dcde440d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetContext.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetContext.tsx @@ -1,7 +1,7 @@ import type { GalleryImageItem, GalleryItem, GalleryItemRef } from '@features/gallery/contracts'; import type { GallerySemanticReference } from '@features/gallery/core/semanticImageQuery'; import type { GallerySettings } from '@features/gallery/core/settings'; -import type { GalleryView } from '@features/gallery/core/types'; +import type { GalleryGeneralAccess, GalleryView } from '@features/gallery/core/types'; import type { GalleryItemsFilter } from '@features/gallery/data/queries'; import type { GalleryItemStarPatchEvent } from '@features/gallery/data/queryCache'; @@ -33,6 +33,8 @@ export interface GalleryActions { moveBoard: (boardId: string, projectId: string | null, destinationLabel: string) => Promise; refresh: () => void; renameBoard: (boardId: string, boardName: string) => Promise; + /** Who else may open the board: its owner's (or an admin's) choice. */ + setBoardGeneralAccess: (boardId: string, generalAccess: GalleryGeneralAccess) => Promise; selectBoard: (boardId: string) => void; selectItem: (item: GalleryItem, selectionPage?: number) => void; selectItemRange: (items: GalleryItemRef[], primaryItem: GalleryItem, selectionPage?: number) => void; @@ -68,6 +70,8 @@ export interface GalleryStarredStrip { export interface GalleryWidgetContextValue { gallery: GalleryStateView; actions: GalleryActions; + /** The server has more than one account, so boards can be shared; absent where it does not apply. */ + boardSharing?: boolean; /** The board list's standing; its failure is the board panel's to show, never the grid's. */ boardsState: GalleryReadState; /** @@ -90,8 +94,6 @@ export interface GalleryWidgetContextValue { /** See `GalleryData.pinRevealIndex`; offered for sparse infinite listings only. */ pinRevealIndex?: (absoluteIndex: number) => void; starredStrip: GalleryStarredStrip; - /** See `GalleryUiAdapter.currentUserId`. */ - currentUserId: string | null; projectId: string; projectName: string; /** Names for the projects other boards belong to, keyed by project id. */ diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx index 2e786dcf631..e021109fc0a 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx @@ -14,12 +14,22 @@ import { GalleryItemActionsProvider, GalleryUiProvider } from './GalleryUiContex import { GalleryStatusChip, GalleryWidgetView } from './GalleryWidgetView'; const mocks = vi.hoisted(() => ({ + boardSharing: undefined as boolean | undefined, galleryData: null as unknown, getItemActionContext: null as null | ComponentProps['getItemActionContext'], })); vi.mock('./GalleryBoardDragMonitor', () => ({ GalleryBoardDragMonitor: () => null })); -vi.mock('./GalleryLayout', () => ({ GalleryLayout: () => null })); +vi.mock('./GalleryLayout', async () => { + const { useGalleryWidget } = await import('./GalleryWidgetContext'); + return { + // What the board panel and menus will read: the context as the widget publishes it. + GalleryLayout: () => { + mocks.boardSharing = useGalleryWidget().boardSharing; + return null; + }, + }; +}); vi.mock('./useGalleryActions', () => ({ useGalleryActions: () => ({}) })); vi.mock('./useGalleryData', () => ({ useGalleryData: () => mocks.galleryData })); vi.mock('./useGalleryStarredStrip', () => ({ @@ -128,6 +138,7 @@ const adapter: GalleryUiAdapter = { projects: [], widgets: { openGallery: () => true, patchGalleryValues: noop }, }; +const sharingAdapter: GalleryUiAdapter = { ...adapter, boardSharing: true }; const runtime = { commands: { register: noop }, hotkeys: { register: noop } }; it('renders the compact gallery status through the localized status-chip key', async () => { @@ -186,6 +197,39 @@ it('exposes sparse ranking pages to item context-menu actions', async () => { expect(mocks.getItemActionContext?.()?.getItemSelectionPage?.(deepItem)).toBe(3); }); +it('publishes whether boards can be shared to the board panel and menus', async () => { + mocks.galleryData = { + boards: [], + boardsState: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'ready' }, + filter: { boardId: 'none' }, + isLoadingItems: false, + isWindowTruncated: false, + items: [], + listing: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'ready' }, + loadMore: noop, + queryError: null, + selectedBoardId: 'none', + setVisibleRange: noop, + total: 0, + }; + + const queryClient = new QueryClient(); + + await act(() => + root?.render( + + + + + + + + ) + ); + + expect(mocks.boardSharing).toBe(true); +}); + describe('external reveal announcements', () => { const REVEALED = 'widgets.gallery.externalGenerationRevealed'; const accountSignal = new AbortController().signal; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx index d320cc0810d..c4fd3cfd6da 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx @@ -91,7 +91,7 @@ export const useGallerySemanticImageQuery = (value: unknown) => export const GalleryWidgetView = ({ presentation, region, runtime }: GalleryWidgetProps) => { const { - currentUserId, + boardSharing = false, gallery: galleryCommands, galleryValues, generateValues, @@ -250,12 +250,12 @@ export const GalleryWidgetView = ({ presentation, region, runtime }: GalleryWidg > ( () => ({ actions, + boardSharing, boardsState, - currentUserId, filter, gallery, isWindowTruncated, @@ -371,8 +371,8 @@ const GalleryWidgetContent = ({ }), [ actions, + boardSharing, boardsState, - currentUserId, filter, gallery, isWindowTruncated, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.test.ts index 4dcfb1e16fa..0a03105e1ca 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.test.ts @@ -1,4 +1,4 @@ -import type { GalleryBoard } from '@features/gallery/core/types'; +import type { GalleryBoard, GalleryBoardAccess } from '@features/gallery/core/types'; import { describe, expect, it } from 'vitest'; @@ -162,3 +162,56 @@ describe('getGalleryBoardGroups', () => { expect(groups.canCreateFromSearch).toBe(true); }); }); + +describe('getGalleryBoardGroups with other people’s boards', () => { + const access = (role: GalleryBoardAccess['role'], adminOverride = false): GalleryBoardAccess => ({ + actions: ['read'], + adminOverride, + generalAccess: 'viewer', + role, + }); + const own = createBoard({ access: access('owner'), id: 'own', name: 'Own work' }); + const sharedZ = createBoard({ access: access('viewer'), id: 'z', name: 'Zines', ownerName: 'Ana' }); + const sharedA = createBoard({ access: access('editor'), id: 'a', name: 'Atlas', ownerName: 'Ben' }); + // An admin is told which of the owner's projects a board is in; it is still not theirs. + const adminOnly = createBoard({ + access: access(null, true), + id: 'theirs', + isInbox: true, + name: 'Their project', + ownerName: 'Dee', + projectId: 'project-1', + }); + const sharedToAdmin = createBoard({ + access: access('viewer', true), + id: 'public', + name: 'Public', + ownerName: 'Cleo', + }); + const sharedArchived = createBoard({ + access: access('viewer'), + archived: true, + id: 'old-shared', + name: 'Old shared', + ownerName: 'Ana', + }); + const boards = [uncategorized, own, sharedZ, sharedA, adminOnly, sharedToAdmin, sharedArchived]; + + it('lists them apart from the caller’s own tiers, by name, a real role before admin rights', () => { + const groups = groupsOf({ boards }); + + expect(ids(groups.projectBoards)).toEqual([]); + expect(ids(groups.libraryBoards)).toEqual(['none', 'own']); + expect(ids(groups.sharedBoards)).toEqual(['a', 'public', 'z']); + expect(ids(groups.otherUsersBoards)).toEqual(['theirs']); + expect(ids(groups.archivedBoards)).toEqual(['old-shared']); + }); + + it('finds someone else’s board by its owner, but never counts its name as taken', () => { + expect(ids(groupsOf({ boards, searchTerm: 'ana' }).sharedBoards)).toEqual(['z']); + expect(groupsOf({ boards, searchTerm: 'ana' }).archivedBoards.map((board) => board.id)).toEqual(['old-shared']); + // The caller's own board is not found by the owner name every one of theirs would share. + expect(ids(groupsOf({ boards: [{ ...own, ownerName: 'Me' }], searchTerm: 'me' }).libraryBoards)).toEqual([]); + expect(groupsOf({ boards, searchTerm: 'zines' }).canCreateFromSearch).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts index fdba9e04611..9761a70dd9e 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts @@ -1,5 +1,11 @@ import type { GalleryBoard } from '@features/gallery/core/types'; +import { + canOnBoard, + isOtherUsersGalleryBoard, + isOwnGalleryBoard, + isSharedGalleryBoard, +} from '@features/gallery/core/boardAccess'; import { getGalleryBoardLabel, getGalleryProjectGroupLabel, @@ -29,37 +35,28 @@ export interface GalleryBoardGroups { otherProjects: GalleryBoardProjectGroup[]; /** The open project's inbox first, then its other boards. */ projectBoards: GalleryBoard[]; + /** Other people's boards the caller has a role on, by name; never in the caller's own tiers. */ + sharedBoards: GalleryBoard[]; + /** Other people's boards an admin sees only through admin rights, by name. */ + otherUsersBoards: GalleryBoard[]; } /** - * A board its owner renames, moves, archives and deletes directly. An inbox is managed through its project; the open - * project's is also known by id, because a draft project's inbox can predate the listing. + * A board renamed, moved, archived and deleted directly, as far as the caller's role allows. An inbox is managed + * through its project; the open project's is also known by id, because a draft project's inbox can predate the listing. */ export const isManagedGalleryBoard = (board: GalleryBoard, projectBoardId: string | null): boolean => board.kind === 'board' && !board.isInbox && board.id !== projectBoardId; -/** Whether the board is the signed-in user's; on a single-user install (`currentUserId` null), every board is. */ -export const isOwnGalleryBoard = (board: GalleryBoard, currentUserId: string | null): boolean => - currentUserId === null || board.ownerId === undefined || board.ownerId === currentUserId; - -/** - * Whether the board can move between the Library and projects, by menu or by dragging. Only into its owner's own - * projects, which are the only ones the caller can name, and only while it is private: a shared or public board stays - * in the Library. An admin may manage someone else's board, but not move it into a project of theirs. - */ -export const canMoveGalleryBoard = ( - board: GalleryBoard, - projectBoardId: string | null, - currentUserId: string | null -): boolean => - isManagedGalleryBoard(board, projectBoardId) && - isOwnGalleryBoard(board, currentUserId) && - (board.visibility ?? 'private') === 'private'; +/** A board the caller can move between the Library and their projects: only its owner has projects to move it into. */ +export const isMovableGalleryBoard = (board: GalleryBoard, projectBoardId: string | null): boolean => + isManagedGalleryBoard(board, projectBoardId) && isOwnGalleryBoard(board) && canOnBoard(board, 'move'); /** * Apply visibility filters while grouping; GET /boards/ cannot filter by project and returns the complete list. * Membership is the board's own `projectId`; `projectBoardId` also claims the open project's inbox, which a draft - * project can know before the listing does. + * project can know before the listing does. Only the caller's own boards join those tiers: someone else's board is + * listed as shared, or for an admin as another user's, whatever project of theirs it is in. */ export const getGalleryBoardGroups = ({ boards, @@ -85,14 +82,22 @@ export const getGalleryBoardGroups = ({ t: GalleryBoardTranslate; }): GalleryBoardGroups => { const normalizedSearchTerm = searchTerm.trim().toLowerCase(); + // Someone else's board is also found by its owner's name, which its row shows. const matchesBoardSearch = (board: GalleryBoard) => - !normalizedSearchTerm || getGalleryBoardLabel(board, t).toLowerCase().includes(normalizedSearchTerm); + !normalizedSearchTerm || + getGalleryBoardLabel(board, t).toLowerCase().includes(normalizedSearchTerm) || + (!isOwnGalleryBoard(board) && (board.ownerName ?? '').toLowerCase().includes(normalizedSearchTerm)); const isOpenProjectBoard = (board: GalleryBoard) => board.id === projectBoardId || (projectId !== null && board.projectId === projectId); + const byName = (left: GalleryBoard, right: GalleryBoard) => left.name.localeCompare(right.name); const uncategorizedBoard = boards.find((board) => board.kind === 'uncategorized') ?? null; const regularBoards = boards.filter((board) => board.kind === 'board' && matchesBoardSearch(board)); - const liveBoards = regularBoards.filter((board) => !board.archived); + const liveBoards = regularBoards.filter((board) => !board.archived && isOwnGalleryBoard(board)); + const sharedBoards = regularBoards.filter((board) => !board.archived && isSharedGalleryBoard(board)).sort(byName); + const otherUsersBoards = regularBoards + .filter((board) => !board.archived && isOtherUsersGalleryBoard(board)) + .sort(byName); const projectBoards = inboxFirst(liveBoards.filter(isOpenProjectBoard)); const libraryBoards = [ @@ -134,7 +139,9 @@ export const getGalleryBoardGroups = ({ const dateBoards = showDates ? boards.filter((board) => board.kind === 'date' && matchesBoardSearch(board)) : []; const archivedBoards = showArchived ? regularBoards.filter( - (board) => board.archived && (showOtherProjects || board.projectId === null || isOpenProjectBoard(board)) + (board) => + board.archived && + (!isOwnGalleryBoard(board) || showOtherProjects || board.projectId === null || isOpenProjectBoard(board)) ) : []; @@ -142,16 +149,19 @@ export const getGalleryBoardGroups = ({ projectBoards.length > 0 || libraryBoards.length > 0 || otherProjects.length > 0 || + sharedBoards.length > 0 || + otherUsersBoards.length > 0 || dateBoards.length > 0 || archivedBoards.length > 0; - // Only the rows on screen count as taken: a name in a hidden project (or an archived board while archived boards - // are hidden) may legitimately be reused, and refusing it would leave the search with nothing to do. + // Only the caller's own rows on screen count as taken: a name in a hidden project (or an archived board while + // archived boards are hidden), or someone else's board, may legitimately be reused, and refusing it would leave the + // search with nothing to do. const hasExactMatch = [ ...projectBoards, ...libraryBoards, ...otherProjects.flatMap((group) => group.boards), ...dateBoards, - ...archivedBoards, + ...archivedBoards.filter(isOwnGalleryBoard), ].some((board) => getGalleryBoardLabel(board, t).toLowerCase() === normalizedSearchTerm); return { @@ -161,6 +171,8 @@ export const getGalleryBoardGroups = ({ hasAnyMatch, libraryBoards, otherProjects, + otherUsersBoards, projectBoards, + sharedBoards, }; }; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardLabels.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardLabels.test.ts index 52acc221e51..c15c570db29 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardLabels.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardLabels.test.ts @@ -2,7 +2,7 @@ import type { GalleryBoard } from '@features/gallery/core/types'; import { describe, expect, it } from 'vitest'; -import { getGalleryCountForView } from './galleryBoardLabels'; +import { getGalleryBoardOwnerLabel, getGalleryCountForView } from './galleryBoardLabels'; const createBoard = (overrides: Partial = {}): GalleryBoard => ({ archived: false, @@ -36,3 +36,27 @@ describe('getGalleryCountForView', () => { expect(getGalleryCountForView(board, 'assets')).toBe(4); }); }); + +describe('getGalleryBoardOwnerLabel', () => { + const t = (key: string, options?: Record) => + options ? `${key}(${JSON.stringify(options)})` : key.replace('widgets.gallery.boardAccess.', ''); + const withAccess = (role: 'owner' | 'viewer' | 'contributor' | 'editor' | null, adminOverride = false) => + createBoard({ + access: { actions: ['read'], adminOverride, generalAccess: 'viewer', role }, + ownerName: 'Ana', + }); + + it('says whose board it is and what the caller may do, and nothing on their own', () => { + expect(getGalleryBoardOwnerLabel(withAccess('owner'), t)).toBeNull(); + expect(getGalleryBoardOwnerLabel(withAccess('viewer'), t)).toBe( + 'widgets.gallery.boardAccess.ownerAndAccess({"access":"viewer","owner":"Ana"})' + ); + expect(getGalleryBoardOwnerLabel(withAccess('editor'), t)).toContain('"access":"editor"'); + // Admin rights say more than a role they exceed. + expect(getGalleryBoardOwnerLabel(withAccess('viewer', true), t)).toContain('"access":"admin"'); + expect(getGalleryBoardOwnerLabel(withAccess(null, true), t)).toContain('"access":"admin"'); + expect(getGalleryBoardOwnerLabel({ ...withAccess('viewer'), ownerName: null }, t)).toContain( + '"owner":"deletedOwner"' + ); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardLabels.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardLabels.ts index 4a46c35233a..b50b5f5037c 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardLabels.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardLabels.ts @@ -11,3 +11,25 @@ export const getGalleryCountForView = (board: GalleryBoard, galleryView: Gallery ? counts.assetCount + counts.assetVideoCount : Math.max(0, counts.imageCount + counts.videoCount - counts.assetVideoCount); }; + +type Translate = (key: string, options?: Record) => string; + +/** + * Whose board someone else's board is and what the caller may do with it, for its row: "{owner} · Can view". Nothing + * for the caller's own boards. A board the server reports no access for keeps its owner's name alone. + */ +export const getGalleryBoardOwnerLabel = (board: GalleryBoard, t: Translate): string | null => { + const { access } = board; + if (access === undefined) { + return board.ownerName ?? null; + } + if (access.role === 'owner') { + return null; + } + const owner = board.ownerName ?? t('widgets.gallery.boardAccess.deletedOwner'); + const level = + access.adminOverride || access.role === null + ? t('widgets.gallery.boardAccess.admin') + : t(`widgets.gallery.boardAccess.${access.role}`); + return t('widgets.gallery.boardAccess.ownerAndAccess', { access: level, owner }); +}; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryDnd.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryDnd.test.ts index 20790dce307..438dcf1e891 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryDnd.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryDnd.test.ts @@ -1,8 +1,10 @@ import type { GalleryItem } from '@features/gallery/contracts'; +import type { GalleryBoard, GalleryBoardAction } from '@features/gallery/core/types'; import { describe, expect, it, vi } from 'vitest'; import { + acceptsGalleryItemMoves, forwardGalleryBoardDrop, GALLERY_SEMANTIC_SEARCH_DROP_ID, getGalleryBoardDragData, @@ -241,6 +243,33 @@ describe('resolveGallerySemanticSearchDrop', () => { }); }); +describe('acceptsGalleryItemMoves', () => { + const board = (overrides: Partial): GalleryBoard => ({ + archived: false, + assetCount: 0, + assetVideoCount: 0, + id: 'b', + imageCount: 0, + isInbox: false, + kind: 'board', + name: 'B', + projectId: null, + videoCount: 0, + ...overrides, + }); + const access = (actions: GalleryBoardAction[]) => ({ + access: { actions, adminOverride: false, generalAccess: 'viewer' as const, role: 'viewer' as const }, + }); + + it('takes items on boards that hold them, unless the caller may only view the board', () => { + expect(acceptsGalleryItemMoves(board({}))).toBe(true); + expect(acceptsGalleryItemMoves(board({ kind: 'uncategorized' }))).toBe(true); + expect(acceptsGalleryItemMoves(board({ kind: 'date' }))).toBe(false); + expect(acceptsGalleryItemMoves(board(access(['read', 'add_media'])))).toBe(true); + expect(acceptsGalleryItemMoves(board(access(['read'])))).toBe(false); + }); +}); + describe('resolveGalleryBoardMove', () => { const dogs = getGalleryBoardDragData({ id: 'dogs', projectId: null }, 'left'); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryDnd.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryDnd.ts index af1ba41f100..1879f10c526 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryDnd.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryDnd.ts @@ -2,6 +2,7 @@ import type { GalleryItem, GalleryItemKey, GalleryItemRef } from '@features/gall import type { GalleryBoard, GalleryBoardKind } from '@features/gallery/core/types'; import { useDndContext, useDroppable, type UseDroppableArguments } from '@dnd-kit/core'; +import { canOnBoard } from '@features/gallery/core/boardAccess'; import { toGalleryItemKey } from '@features/gallery/core/items'; import { createContext } from 'react'; @@ -126,9 +127,16 @@ export const resolveGalleryBoardMove = ( : null; /** Real boards and Uncategorized hold items; date boards are views and accept no moves. */ -export const acceptsGalleryItemMoves = (boardKind: GalleryBoardKind): boolean => +const holdsGalleryItems = (boardKind: GalleryBoardKind): boolean => boardKind === 'board' || boardKind === 'uncategorized'; +/** + * Whether a board row takes dropped items: one that holds items, unless the caller may only view it. Such a board is + * no destination at all, rather than one that refuses after the drop. + */ +export const acceptsGalleryItemMoves = (board: GalleryBoard): boolean => + holdsGalleryItems(board.kind) && canOnBoard(board, 'add_media'); + /** Droppable id for the search field: dropping a gallery image there searches by similarity. */ export const GALLERY_SEMANTIC_SEARCH_DROP_ID = 'gallery-semantic-search-drop'; @@ -220,7 +228,7 @@ export const resolveGalleryBoardDrop = ( if ( !isGalleryItemDragData(activeData) || !isGalleryBoardDropData(overData) || - !acceptsGalleryItemMoves(overData.boardKind) + !holdsGalleryItems(overData.boardKind) ) { return null; } diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts index 6a836e327ec..0184798ebe7 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts @@ -1,11 +1,13 @@ import type { GalleryImageItem, GalleryItem, GalleryVideoItem } from '@features/gallery/core/items'; -import type { GalleryBoard, GeneratedImageContract } from '@features/gallery/core/types'; +import type { GalleryBoard, GalleryBoardAction, GeneratedImageContract } from '@features/gallery/core/types'; +import { getGallerySettings } from '@features/gallery/core/settings'; import { describe, expect, it } from 'vitest'; import { getBoardCounts, getGalleryAutoAddBoardId, + getGalleryRedirectedDestination, getGalleryDestinationBoardId, getGallerySelectedBoardId, getGallerySelectedImageQuery, @@ -171,6 +173,7 @@ describe('gallery state view', () => { boardPanelHeightPx: 280, boardPanelWidthPx: 240, collapsedBoardSections: [], + expandedBoardSections: [], imageDensityPercent: 50, imageOrderDir: 'DESC', mediaFilter: 'all', @@ -462,13 +465,64 @@ describe('getGalleryDestinationBoardId', () => { }); describe('getGalleryAutoAddBoardId', () => { + const anyBoard = () => true; + it('follows the gallery destination until a board is fixed', () => { - expect(getGalleryAutoAddBoardId({ projectBoardId: 'project', selectedBoardId: 'picked' })).toBe('picked'); - expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'follow', projectBoardId: 'project' })).toBe('project'); + expect(getGalleryAutoAddBoardId({ projectBoardId: 'project', selectedBoardId: 'picked' }, anyBoard)).toBe('picked'); + expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'follow', projectBoardId: 'project' }, anyBoard)).toBe('project'); }); it('uses a fixed board, including Uncategorized, whatever the selection', () => { - expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'fixed', selectedBoardId: 'picked' })).toBe('fixed'); - expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'none', selectedBoardId: 'picked' })).toBe('none'); + expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'fixed', selectedBoardId: 'picked' }, anyBoard)).toBe('fixed'); + expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'none', selectedBoardId: 'picked' }, anyBoard)).toBe('none'); + }); + + it('sends results a view-only board refuses to the project inbox, followed or fixed', () => { + const viewOnly = (boardId: string) => boardId !== 'shared'; + + expect(getGalleryAutoAddBoardId({ projectBoardId: 'inbox', selectedBoardId: 'shared' }, viewOnly)).toBe('inbox'); + expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'shared', projectBoardId: 'inbox' }, viewOnly)).toBe('inbox'); + expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'shared' }, viewOnly)).toBe('none'); + // Uncategorized is always the caller's own. + expect(getGalleryAutoAddBoardId({ autoAddBoardId: 'none', projectBoardId: 'inbox' }, () => false)).toBe('none'); + }); +}); + +describe('getGalleryRedirectedDestination', () => { + const createBoard = (id: string): GalleryBoard => ({ + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 0, + isInbox: false, + kind: 'board', + name: id, + projectId: null, + videoCount: 0, + }); + const access = (actions: GalleryBoardAction[]) => ({ + access: { actions, adminOverride: false, generalAccess: 'viewer' as const, role: 'viewer' as const }, + }); + const boards = [ + { ...createBoard('shared'), ...access(['read']) }, + { ...createBoard('moodboard'), ...access(['read', 'add_media']) }, + createBoard('inbox'), + ]; + const view = (settings: { autoAddBoardId: string }, selectedBoardId: string) => ({ + boards, + projectBoardId: 'inbox', + selectedBoardId, + settings: { ...getGallerySettings({}), ...settings }, + }); + + it('names the board that refused results and where they go instead', () => { + expect(getGalleryRedirectedDestination(view({ autoAddBoardId: 'follow' }, 'shared'))).toEqual({ + boardId: 'inbox', + refusedBoard: boards[0], + }); + expect(getGalleryRedirectedDestination(view({ autoAddBoardId: 'shared' }, 'inbox'))?.boardId).toBe('inbox'); + expect(getGalleryRedirectedDestination(view({ autoAddBoardId: 'follow' }, 'moodboard'))).toBeNull(); + expect(getGalleryRedirectedDestination(view({ autoAddBoardId: 'moodboard' }, 'shared'))).toBeNull(); }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts index acf87d38ab8..a0d7a235083 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts @@ -2,6 +2,7 @@ import type { GalleryBoard, GalleryImage, GalleryOrderDir, GalleryView } from '@ import type { IconRadioMenuProps } from '@platform/ui/Menu'; import type { TFunction } from 'i18next'; +import { canOnBoard } from '@features/gallery/core/boardAccess'; import { isDateBoardId, toGalleryItemKey, @@ -156,11 +157,42 @@ export const getGalleryDestinationBoardId = (values: Record): s : getGalleryProjectBoardId(values); }; -/** Where results without a board of their own go: the auto-add board, or the destination above while following. */ -export const getGalleryAutoAddBoardId = (values: Record): string | null => { +/** + * Where results without a board of their own go: the auto-add board, or the destination above while following. A + * board `canAddTo` refuses, someone else's the caller can only view, sends them to the project's inbox instead. + */ +export const getGalleryAutoAddBoardId = ( + values: Record, + canAddTo: (boardId: string) => boolean +): string | null => { const { autoAddBoardId } = getGallerySettings(values); + const target = autoAddBoardId === GALLERY_AUTO_ADD_FOLLOW ? getGalleryDestinationBoardId(values) : autoAddBoardId; + + return target !== null && target !== 'none' && !canAddTo(target) + ? (getGalleryProjectBoardId(values) ?? 'none') + : target; +}; - return autoAddBoardId === GALLERY_AUTO_ADD_FOLLOW ? getGalleryDestinationBoardId(values) : autoAddBoardId; +/** + * Where new results go when the board they would land on (the auto-add board, or the selected board while following + * it) is someone else's the caller may only view: the project's inbox, and the board that refused them. Null when + * nothing is redirected. The panel's view of `getGalleryAutoAddBoardId`, resolved against the listed boards. + */ +export const getGalleryRedirectedDestination = ( + view: Pick +): { boardId: string; refusedBoard: GalleryBoard } | null => { + const { autoAddBoardId } = view.settings; + const intendedId = + autoAddBoardId !== GALLERY_AUTO_ADD_FOLLOW + ? autoAddBoardId + : isDateBoardId(view.selectedBoardId) + ? view.projectBoardId + : view.selectedBoardId; + const refusedBoard = view.boards.find((board) => board.id === intendedId); + + return refusedBoard && !canOnBoard(refusedBoard, 'add_media') + ? { boardId: view.projectBoardId ?? 'none', refusedBoard } + : null; }; /** diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerPopover.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerPopover.browser.test.tsx index 0d04b0dbb72..4fdc7acc0bb 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerPopover.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerPopover.browser.test.tsx @@ -67,6 +67,10 @@ vi.mock('@features/gallery/data/backend', async (importOriginal) => ({ vi.mock('@features/gallery/data/queryCache', async (importOriginal) => ({ ...(await importOriginal>()), + // The picker's board takes uploads; the destination rule has its own tests with the upload action. + canAddToCachedGalleryBoard: () => true, + ensureGalleryBoardDestinations: () => null, + invalidateGalleryBoardDestinations: vi.fn(), invalidateGallery: (...args: unknown[]) => mocks.invalidateGallery(...args), })); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerView.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerView.tsx index 311276ba5ea..8a3b19a99be 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerView.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerView.tsx @@ -266,6 +266,8 @@ export const GalleryPickerView = ({ id: `project:${group.projectId}`, label: group.label, })), + { boards: groups.sharedBoards, id: 'shared', label: t('widgets.gallery.boardGroups.shared') }, + { boards: groups.otherUsersBoards, id: 'other-users', label: t('widgets.gallery.boardGroups.otherUsers') }, { boards: groups.dateBoards, id: 'dates', label: t('widgets.gallery.boardGroups.byDate') }, { boards: groups.archivedBoards, id: 'archived', label: t('common.archived') }, ].filter((group) => group.boards.length > 0); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.browser.test.tsx index b5b5e3cde5d..70f09dc7a13 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.browser.test.tsx @@ -4,6 +4,7 @@ import type { GalleryUiAdapter } from '@features/gallery/react'; import { GALLERY_AUTO_ADD_FOLLOW, getGallerySettings } from '@features/gallery/core/settings'; import { GalleryUiProvider } from '@features/gallery/react'; import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { ApiError } from '@platform/transport/http'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, createRef, type Ref, type ReactNode, useCallback, useImperativeHandle, useRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -19,7 +20,12 @@ const mocks = vi.hoisted(() => ({ ensureProjectOnServer: vi.fn(async () => {}), downloadBlob: vi.fn(), downloadGalleryArchive: vi.fn(), + // The ensured listing's answer per board; the panel's `boards` fixture names only board-1 and shared-viewer. + canAddToCachedGalleryBoard: vi.fn((boardId: string) => boardId === 'board-1'), + ensureGalleryBoardDestinations: vi.fn((): Promise | null => null), + getCachedGalleryBoard: vi.fn((_boardId: string): unknown => undefined), invalidateGallery: vi.fn(), + invalidateGalleryBoardDestinations: vi.fn(), notificationsAdd: vi.fn(), notificationsReportError: vi.fn(), patchGalleryBoardCaches: vi.fn((..._args: unknown[]) => vi.fn()), @@ -33,6 +39,7 @@ vi.mock('@features/gallery/data/backend', () => ({ deleteGalleryBoard: (...args: unknown[]) => mocks.deleteGalleryBoard(...args), downloadGalleryArchive: (...args: unknown[]) => mocks.downloadGalleryArchive(...args), isDateBoardId: (boardId: string) => boardId.startsWith('by_date:'), + isInvalidGalleryBoardDestination: (boardId: string) => boardId.startsWith('by_date:'), updateGalleryBoard: (...args: unknown[]) => mocks.updateGalleryBoard(...args), uploadGalleryImage: (...args: unknown[]) => mocks.uploadGalleryImage(...args), uploadGalleryVideo: (...args: unknown[]) => mocks.uploadGalleryVideo(...args), @@ -76,7 +83,12 @@ vi.mock('react-i18next', () => ({ })); vi.mock('@features/gallery/data/queryCache', () => ({ + addGalleryBoardToCaches: vi.fn(), + canAddToCachedGalleryBoard: (boardId: string) => mocks.canAddToCachedGalleryBoard(boardId), + ensureGalleryBoardDestinations: () => mocks.ensureGalleryBoardDestinations(), + getCachedGalleryBoard: (boardId: string) => mocks.getCachedGalleryBoard(boardId), invalidateGallery: (...args: unknown[]) => mocks.invalidateGallery(...args), + invalidateGalleryBoardDestinations: (...args: unknown[]) => mocks.invalidateGalleryBoardDestinations(...args), patchGalleryBoardCaches: (...args: unknown[]) => mocks.patchGalleryBoardCaches(...args), })); @@ -125,6 +137,20 @@ const Probe = ({ projectId: null, videoCount: 1, }, + { + access: { actions: ['read'], adminOverride: false, generalAccess: 'viewer', role: 'viewer' }, + archived: false, + assetCount: 0, + assetVideoCount: 0, + id: 'shared-viewer', + imageCount: 0, + isInbox: false, + kind: 'board', + name: 'Client references', + ownerName: 'Cleo', + projectId: null, + videoCount: 0, + }, { archived: false, assetCount: 0, @@ -196,6 +222,22 @@ const adapter: GalleryUiAdapter = { widgets: { openGallery: () => true, patchGalleryValues }, }; +/** The shared board as the ensured listing describes it once the caller may only view it. */ +const VIEW_ONLY_SHARED_BOARD = { + access: { actions: ['read'], adminOverride: false, generalAccess: 'viewer', role: 'viewer' }, + archived: false, + assetCount: 0, + assetVideoCount: 0, + id: 'shared-viewer', + imageCount: 0, + isInbox: false, + kind: 'board', + name: 'Client references', + ownerName: 'Cleo', + projectId: null, + videoCount: 0, +}; + let selectedBoardId = 'board-1'; let galleryView: 'images' | 'assets' = 'images'; @@ -215,6 +257,9 @@ const renderProbe = async () => { beforeEach(async () => { vi.clearAllMocks(); + mocks.canAddToCachedGalleryBoard.mockImplementation((boardId: string) => boardId === 'board-1'); + mocks.ensureGalleryBoardDestinations.mockReturnValue(null); + mocks.getCachedGalleryBoard.mockReturnValue(undefined); accountLifecycle.activate('user-a'); selectedBoardId = 'board-1'; galleryView = 'images'; @@ -505,6 +550,147 @@ describe('optimistic board updates', () => { }); }); +describe('board access', () => { + it('sets who can open a board at once, names the setting, and refetches', async () => { + mocks.updateGalleryBoard.mockResolvedValueOnce({}); + + await act(async () => { + await actionsRef.current?.setBoardGeneralAccess('board-1', 'viewer'); + }); + + expect(mocks.patchGalleryBoardCaches).toHaveBeenCalledWith(expect.anything(), 'board-1', { + generalAccess: 'viewer', + }); + expect(mocks.updateGalleryBoard).toHaveBeenCalledWith('board-1', { generalAccess: 'viewer' }, expect.anything()); + expect(mocks.notificationsAdd).toHaveBeenCalledWith({ + kind: 'success', + message: 'Board 1', + title: 'widgets.gallery.boardAccess.changed', + }); + expect(mocks.patchGalleryBoardCaches.mock.results[0]?.value).not.toHaveBeenCalled(); + expect(mocks.invalidateGallery).toHaveBeenCalledOnce(); + }); + + it.each([ + // The role no longer allows the action; the board still exists and is readable. + [ + 'board-1', + 403, + { code: 'board_action_denied', message: 'Not authorized to change this board' }, + 'widgets.gallery.boardAccess.stale', + 'Board 1', + ], + // The board exists but stopped being readable: an owner restricted it, or a membership was revoked. + [ + 'shared-viewer', + 403, + { code: 'board_unreadable', message: 'Not authorized to access this board' }, + 'widgets.gallery.boardAccess.lost', + 'Client references', + ], + // Only a board that is gone answers 404. + ['board-1', 404, 'Board not found', 'widgets.gallery.boardAccess.gone', 'Board 1'], + ])( + 'rolls back a refusal of %s answered %i %o, says why, and refetches', + async (boardId, status, detail, title, name) => { + mocks.updateGalleryBoard.mockRejectedValueOnce(new ApiError(JSON.stringify({ detail }), status)); + + await act(async () => { + await actionsRef.current?.renameBoard(boardId, 'Renamed'); + }); + + expect(mocks.patchGalleryBoardCaches.mock.results[0]?.value).toHaveBeenCalledOnce(); + expect(mocks.notificationsAdd).toHaveBeenCalledWith({ kind: 'info', message: name, title }); + expect(mocks.notificationsReportError).not.toHaveBeenCalled(); + expect(mocks.invalidateGallery).toHaveBeenCalledOnce(); + } + ); + + it.each([ + [ + 'set who can open', + mocks.updateGalleryBoard, + () => actionsRef.current?.setBoardGeneralAccess('board-1', 'viewer'), + ], + ['archive', mocks.updateGalleryBoard, () => actionsRef.current?.archiveBoard('board-1', true)], + ['delete', mocks.deleteGalleryBoard, () => actionsRef.current?.deleteBoard('board-1', false)], + ['download', mocks.downloadGalleryArchive, () => actionsRef.current?.downloadBoard('board-1')], + ])('treats a refused attempt to %s the board as changed access', async (_action, request, run) => { + request.mockRejectedValueOnce(new ApiError(JSON.stringify({ detail: 'Not allowed' }), 403)); + + await act(async () => { + await run(); + }); + + expect(mocks.notificationsAdd).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'info', title: 'widgets.gallery.boardAccess.stale' }) + ); + expect(mocks.notificationsReportError).not.toHaveBeenCalled(); + expect(mocks.invalidateGallery).toHaveBeenCalledOnce(); + }); + + it('reports a move to a project that is gone as the server says, not as lost access', async () => { + mocks.updateGalleryBoard.mockRejectedValueOnce(new ApiError(JSON.stringify({ detail: 'Project not found' }), 404)); + + await act(async () => { + await actionsRef.current?.moveBoard('board-1', null, 'Library'); + }); + + expect(mocks.notificationsReportError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Project not found' }) + ); + expect(mocks.notificationsAdd).not.toHaveBeenCalled(); + // Either the board or its destination is gone: the listing is refetched all the same. + expect(mocks.invalidateGallery).toHaveBeenCalledOnce(); + }); +}); + +describe('archiving the auto-add board', () => { + const archiveFailingLater = async () => { + let rejectUpdate: (error: Error) => void = () => undefined; + mocks.updateGalleryBoard.mockReturnValue( + new Promise((_resolve, reject) => { + rejectUpdate = reject; + }) + ); + let archive!: Promise; + await act(() => { + archive = actionsRef.current!.archiveBoard('board-1', true); + }); + + return { archive, reject: () => rejectUpdate(new Error('archive failed')) }; + }; + + it('stops auto-adding there, and pins it again if the archive is refused', async () => { + galleryValues = { autoAddBoardId: 'board-1' }; + await renderProbe(); + const { archive, reject } = await archiveFailingLater(); + + expect(updateSettings).toHaveBeenCalledExactlyOnceWith({ autoAddBoardId: GALLERY_AUTO_ADD_FOLLOW }); + + // Reflect the optimistic step in the live gallery the failure path reads. + galleryValues = { autoAddBoardId: GALLERY_AUTO_ADD_FOLLOW }; + await renderProbe(); + reject(); + await act(() => archive); + + expect(updateSettings).toHaveBeenLastCalledWith({ autoAddBoardId: 'board-1' }); + }); + + it('leaves an auto-add board the user chose while the refused archive was in flight', async () => { + galleryValues = { autoAddBoardId: 'board-1' }; + await renderProbe(); + const { archive, reject } = await archiveFailingLater(); + + galleryValues = { autoAddBoardId: 'board-3' }; + await renderProbe(); + reject(); + await act(() => archive); + + expect(updateSettings).toHaveBeenCalledExactlyOnceWith({ autoAddBoardId: GALLERY_AUTO_ADD_FOLLOW }); + }); +}); + describe('localized board labels', () => { it('uses the localized Uncategorized label in board notifications', async () => { mocks.downloadGalleryArchive.mockResolvedValue({ blob: new Blob(['archive']), fileName: 'gallery.zip' }); @@ -620,6 +806,83 @@ describe('mixed gallery upload', () => { }); }); + it('refuses an upload to a board the caller may only view before starting any request', async () => { + selectedBoardId = 'shared-viewer'; + mocks.getCachedGalleryBoard.mockReturnValue(VIEW_ONLY_SHARED_BOARD); + await renderProbe(); + + await act(async () => { + await actionsRef.current?.uploadFiles([new File(['image'], 'photo.png', { type: 'image/png' })]); + }); + + expect(mocks.uploadGalleryImage).not.toHaveBeenCalled(); + expect(mocks.notificationsReportError).toHaveBeenCalledExactlyOnceWith({ + area: 'gallery-upload', + message: 'widgets.gallery.uploadsUnavailableForViewOnlyBoard', + namespace: 'gallery', + }); + expect(mocks.invalidateGalleryBoardDestinations).toHaveBeenCalledOnce(); + }); + + it.each([ + // The panel still lists board-1 with add_media; the ensured listing holds it as view-only: the role changed. + ['board-1', 'widgets.gallery.uploadsUnavailableForViewOnlyBoard', { ...VIEW_ONLY_SHARED_BOARD, id: 'board-1' }], + // Listed by the panel with add_media; the ensured listing no longer holds it: deleted or revoked since. + ['board-1', 'widgets.gallery.uploadsUnavailableForBoard', undefined], + // Nothing on screen names it. + ['gone', 'widgets.gallery.uploadsUnavailableForUnlistedBoard', undefined], + ])( + 'refuses an upload to %s as the ensured listing says (%s), and reads the listing again', + async (boardId, message, cachedBoard) => { + selectedBoardId = boardId; + mocks.getCachedGalleryBoard.mockReturnValue(cachedBoard); + await renderProbe(); + let settleListing!: () => void; + mocks.ensureGalleryBoardDestinations.mockReturnValue( + new Promise((resolve) => { + settleListing = resolve; + }) + ); + mocks.canAddToCachedGalleryBoard.mockReturnValue(false); + + let upload: Promise | undefined; + await act(() => { + upload = actionsRef.current?.uploadFiles([new File(['image'], 'photo.png', { type: 'image/png' })]); + }); + + // Judged only once the listing is in. + expect(mocks.ensureGalleryBoardDestinations).toHaveBeenCalledOnce(); + expect(mocks.canAddToCachedGalleryBoard).not.toHaveBeenCalled(); + await act(async () => { + settleListing(); + await upload; + }); + + expect(mocks.canAddToCachedGalleryBoard).toHaveBeenCalledWith(boardId); + expect(mocks.uploadGalleryImage).not.toHaveBeenCalled(); + expect(mocks.notificationsReportError).toHaveBeenCalledExactlyOnceWith({ + area: 'gallery-upload', + message, + namespace: 'gallery', + }); + expect(mocks.invalidateGalleryBoardDestinations).toHaveBeenCalledOnce(); + expect(mocks.invalidateGallery).not.toHaveBeenCalled(); + } + ); + + it('sends an upload to Uncategorized without ensuring any listing', async () => { + selectedBoardId = 'none'; + await renderProbe(); + mocks.uploadGalleryImage.mockResolvedValue(imageUpload('photo.png', '2026-07-30T00:00:00Z')); + + await act(async () => { + await actionsRef.current?.uploadFiles([new File(['image'], 'photo.png', { type: 'image/png' })]); + }); + + expect(mocks.ensureGalleryBoardDestinations).not.toHaveBeenCalled(); + expect(mocks.uploadGalleryImage).toHaveBeenCalledWith(expect.any(File), 'none', expect.anything()); + }); + it('retains concurrent image uploads while running videos sequentially and continuing after failure', async () => { let resolveFirstImage: ((value: ReturnType) => void) | undefined; let resolveSecondImage: ((value: ReturnType) => void) | undefined; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.ts index 4300910b9e8..b33c6c5bfe1 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.ts @@ -9,13 +9,14 @@ import { downloadGalleryArchive, updateGalleryBoard, } from '@features/gallery/data/backend'; -import { invalidateGallery, patchGalleryBoardCaches } from '@features/gallery/data/queryCache'; +import { addGalleryBoardToCaches, invalidateGallery, patchGalleryBoardCaches } from '@features/gallery/data/queryCache'; import { downloadBlob } from '@platform/browser/downloadBlob'; import { assertAccountScopeCurrent, captureAccountScope, isAccountScopeCurrent, } from '@platform/state/accountLifecycle'; +import { ApiError, getApiErrorCode, getApiErrorMessage } from '@platform/transport/http'; import { useQueryClient } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -25,7 +26,11 @@ import type { GalleryActions } from './GalleryWidgetContext'; import { useGalleryUi } from './GalleryUiContext'; import { useGalleryUploadAction } from './useGalleryUploadAction'; -const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const toErrorMessage = (error: unknown): string => + getApiErrorMessage(error, error instanceof Error ? error.message : String(error)); + +/** The server's 403 code for a board that exists but the caller may not read; other 403s refuse an action. */ +const BOARD_UNREADABLE_CODE = 'board_unreadable'; export const useGalleryActions = ({ boards, @@ -49,6 +54,35 @@ export const useGalleryActions = ({ return useMemo(() => { const recordError = (error: unknown) => notifications.reportError({ area: 'gallery-actions', message: toErrorMessage(error), namespace: 'gallery' }); + /** + * A board action the server refuses (403: the board exists but the caller may no longer read it, or their role no + * longer allows the action) or answers as missing (404: deleted) means the listing's view of the board is stale: + * say so plainly and refetch, so the board's menu and place in the list follow what the server says now. + * `missingIsBoard` is false where a 404 can name something else, such as a move's destination project. + */ + const recordBoardError = (boardId: string, error: unknown, { missingIsBoard = true } = {}) => { + const status = error instanceof ApiError ? error.status : null; + if (status === 403 || (status === 404 && missingIsBoard)) { + notifications.add({ + kind: 'info', + message: getBoardName(boardId), + title: t( + status === 404 + ? 'widgets.gallery.boardAccess.gone' + : getApiErrorCode(error) === BOARD_UNREADABLE_CODE + ? 'widgets.gallery.boardAccess.lost' + : 'widgets.gallery.boardAccess.stale' + ), + }); + refresh(); + return; + } + recordError(error); + // Whichever was missing, the board or where it was going, the listing may be out of date. + if (status === 404) { + refresh(); + } + }; const recordSuccess = (title: string, message?: string) => notifications.add({ kind: 'success', message, title }); const refresh = () => void invalidateGallery(queryClient); const getBoard = (boardId: string) => boards.find((board) => board.id === boardId); @@ -65,10 +99,16 @@ export const useGalleryActions = ({ const owner = captureAccountScope(); const rollback = patchGalleryBoardCaches(queryClient, boardId, { archived }); const movedSelectionAway = archived && boardId === selectedBoardId; + // An archived board is out of the way, as a deleted one is: results stop going to it rather than to a board + // nothing on screen shows. + const stoppedAutoAdding = archived && autoAddBoardId === boardId; if (movedSelectionAway) { gallery.selectBoard('none'); } + if (stoppedAutoAdding) { + gallery.updateSettings({ autoAddBoardId: GALLERY_AUTO_ADD_FOLLOW }); + } try { await updateGalleryBoard(boardId, { archived }, owner.signal); @@ -87,11 +127,16 @@ export const useGalleryActions = ({ rollback(); - if (movedSelectionAway && getCurrentGalleryLocation().selectedBoardId === 'none') { + // Each only while the user has not chosen again since. + const current = getCurrentGalleryLocation(); + if (movedSelectionAway && current.selectedBoardId === 'none') { gallery.selectBoard(boardId); } + if (stoppedAutoAdding && current.autoAddBoardId === GALLERY_AUTO_ADD_FOLLOW) { + gallery.updateSettings({ autoAddBoardId: boardId }); + } - recordError(error); + recordBoardError(boardId, error); } }, createBoard: async (boardName, projectId) => { @@ -105,6 +150,8 @@ export const useGalleryActions = ({ const board = await createGalleryBoard(boardName, projectId, owner.signal); + assertAccountScopeCurrent(owner); + await addGalleryBoardToCaches(queryClient, board); assertAccountScopeCurrent(owner); gallery.selectBoard(board.id); recordSuccess(t('widgets.gallery.boardCreated', { name: board.name })); @@ -153,7 +200,7 @@ export const useGalleryActions = ({ return; } - recordError(error); + recordBoardError(boardId, error); } }, downloadBoard: async (boardId) => { @@ -182,7 +229,7 @@ export const useGalleryActions = ({ return; } - recordError(error); + recordBoardError(boardId, error); } }, exportProject, @@ -233,7 +280,7 @@ export const useGalleryActions = ({ gallery.updateSettings({ autoAddBoardId: boardId }); } - recordError(error); + recordBoardError(boardId, error, { missingIsBoard: false }); } }, refresh, @@ -253,7 +300,31 @@ export const useGalleryActions = ({ } rollback(); - recordError(error); + recordBoardError(boardId, error); + } + }, + setBoardGeneralAccess: async (boardId, generalAccess) => { + const owner = captureAccountScope(); + const rollback = patchGalleryBoardCaches(queryClient, boardId, { generalAccess }); + + try { + await updateGalleryBoard(boardId, { generalAccess }, owner.signal); + + assertAccountScopeCurrent(owner); + recordSuccess( + t('widgets.gallery.boardAccess.changed', { + access: t(`widgets.gallery.boardAccess.general.${generalAccess}`), + }), + getBoardName(boardId) + ); + refresh(); + } catch (error: unknown) { + if (!isAccountScopeCurrent(owner)) { + return; + } + + rollback(); + recordBoardError(boardId, error); } }, selectBoard: gallery.selectBoard, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.browser.test.tsx index 434a55201e9..230650d7cc1 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.browser.test.tsx @@ -1,17 +1,24 @@ +/* oxlint-disable react-perf/jsx-no-new-object-as-prop */ import type { GalleryItem } from '@features/gallery/core/items'; import type { GalleryImage } from '@features/gallery/core/types'; import { getGallerySettings } from '@features/gallery/core/settings'; import { fetchGalleryItemsPage } from '@features/gallery/data/queries'; -import { invalidateGallery, patchGalleryItemCaches } from '@features/gallery/data/queryCache'; +import { + canAddToCachedGalleryBoard, + invalidateGallery, + patchGalleryItemCaches, +} from '@features/gallery/data/queryCache'; import { accountLifecycle } from '@platform/state/accountLifecycle'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, useEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GalleryUiAdapter } from './GalleryUiContext'; import type { GalleryData } from './useGalleryData'; +import { GalleryUiProvider } from './GalleryUiContext'; import { useGalleryData } from './useGalleryData'; const mocks = vi.hoisted(() => ({ listGalleryBoards: vi.fn(), listGalleryItems: vi.fn() })); @@ -65,16 +72,25 @@ const recentImages: GalleryImage[] = [ const Probe = ({ page = 0, paginated = false, + projectBoardId = null, searchTerm = '', recentImages = [], -}: { page?: number; paginated?: boolean; recentImages?: GalleryImage[]; searchTerm?: string } = {}) => { + selectedBoardId = null, +}: { + page?: number; + paginated?: boolean; + projectBoardId?: string | null; + recentImages?: GalleryImage[]; + searchTerm?: string; + selectedBoardId?: string | null; +} = {}) => { const data = useGalleryData({ galleryView: 'images', page, - projectBoardId: null, + projectBoardId, recentImages, searchTerm, - selectedBoardId: null, + selectedBoardId, settings: paginated ? paginatedSettings : settings, sparseViewport: true, }); @@ -922,3 +938,69 @@ describe('useGalleryData sparse page subscriptions', () => { expect(pageCallCounts.get(6_060)).toBe(4); }); }); + +describe('useGalleryData selected board the listing lacks', () => { + const createBoard = (id: string, overrides: Record = {}) => ({ + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 1, + isInbox: false, + kind: 'board' as const, + name: id, + projectId: null, + videoCount: 0, + ...overrides, + }); + + /** + * A board the server now answers 403 for (an owner set it to Restricted) is absent from the listing but exists, so + * the project keeps naming it. The gallery shows the project board meanwhile and writes nothing back: the saved + * selection returns on its own once access is granted again. Results and uploads meanwhile go to the inbox, not to + * the board that would refuse them. + */ + it('shows the project board for a selected board it cannot read, without rewriting the saved selection', async () => { + const inbox = createBoard('inbox', { isInbox: true, projectId: 'project-1' }); + mocks.listGalleryBoards.mockResolvedValue([inbox, createBoard('own')]); + const selectBoard = vi.fn(); + const patchGalleryValues = vi.fn(); + const adapter = { + boardSharing: true, + gallery: { selectBoard }, + widgets: { patchGalleryValues }, + } as unknown as GalleryUiAdapter; + + await act(() => + root?.render( + + + + + + ) + ); + await vi.waitFor(() => { + expect(latestData?.boards.map((board) => board.id)).toEqual(['inbox', 'own']); + }); + + expect(latestData?.selectedBoardId).toBe('inbox'); + expect(latestData?.boardsState.status).toBe('ready'); + expect(selectBoard).not.toHaveBeenCalled(); + expect(patchGalleryValues).not.toHaveBeenCalled(); + // Where results and uploads go meanwhile: the inbox, never the board the listing lacks. + expect(canAddToCachedGalleryBoard('restricted', queryClient!)).toBe(false); + expect(canAddToCachedGalleryBoard('inbox', queryClient!)).toBe(true); + // The items shown are the project board's; the saved selection is still the restricted board's id. + expect(mocks.listGalleryItems.mock.calls.at(-1)?.[0]).toMatchObject({ boardId: 'inbox' }); + + // Access granted again: the listing holds the board and the saved selection shows without any write. + mocks.listGalleryBoards.mockResolvedValue([inbox, createBoard('own'), createBoard('restricted')]); + await act(() => invalidateGallery(queryClient!)); + await vi.waitFor(() => { + expect(latestData?.selectedBoardId).toBe('restricted'); + }); + expect(selectBoard).not.toHaveBeenCalled(); + expect(canAddToCachedGalleryBoard('restricted', queryClient!)).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts index 5608e3d1c60..75d2719a1bf 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts @@ -35,6 +35,7 @@ import { resolveGallerySelectedBoardId, type GalleryReadState, } from './galleryStateView'; +import { useHostGalleryBoards } from './GalleryUiContext'; /** The listing's read state, plus whether a further page is on its way. */ export interface GalleryListingState extends GalleryReadState { @@ -126,13 +127,11 @@ export const mapGalleryItemPageSlots = ({ return slots; }; -const EMPTY_BOARDS: GalleryBoard[] = []; - const useGalleryBoards = ({ settings }: { settings: GallerySettings }) => { const { data, error, errorUpdateCount, isError, isFetching, refetch } = useQuery( galleryBoardsOptions(getGalleryListingBoardsQuery(settings)) ); - const boards = data ?? EMPTY_BOARDS; + const boards = useHostGalleryBoards(data); const status = getGalleryReadStatus({ errorUpdateCount, hasData: data !== undefined, isError }, boards.length); const isFailed = status === 'error' || status === 'stale-error'; const retry = useCallback(async () => { diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadAction.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadAction.browser.test.tsx index bb55c75f1ad..4a4ef6a0475 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadAction.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadAction.browser.test.tsx @@ -13,6 +13,9 @@ import type { GalleryActions } from './GalleryWidgetContext'; import { useGalleryUploadAction } from './useGalleryUploadAction'; const mocks = vi.hoisted(() => ({ + // The ensured listing's answer per board; the panel's `boards` fixture names only board-1. + canAddToCachedGalleryBoard: vi.fn((boardId: string) => boardId === 'board-1' || boardId === 'board-9'), + ensureGalleryBoardDestinations: vi.fn((): Promise | null => null), invalidateGallery: vi.fn(), notificationsAdd: vi.fn(), notificationsReportError: vi.fn(), @@ -22,12 +25,17 @@ const mocks = vi.hoisted(() => ({ vi.mock('@features/gallery/data/backend', () => ({ isDateBoardId: (boardId: string) => boardId.startsWith('by_date:'), + isInvalidGalleryBoardDestination: (boardId: string) => boardId.startsWith('by_date:'), uploadGalleryImage: (...args: unknown[]) => mocks.uploadGalleryImage(...args), uploadGalleryVideo: (...args: unknown[]) => mocks.uploadGalleryVideo(...args), })); vi.mock('@features/gallery/data/queryCache', () => ({ + canAddToCachedGalleryBoard: (boardId: string) => mocks.canAddToCachedGalleryBoard(boardId), + ensureGalleryBoardDestinations: () => mocks.ensureGalleryBoardDestinations(), + getCachedGalleryBoard: () => undefined, invalidateGallery: (...args: unknown[]) => mocks.invalidateGallery(...args), + invalidateGalleryBoardDestinations: vi.fn(), })); vi.mock('react-i18next', () => ({ @@ -383,9 +391,11 @@ describe('focused gallery upload action', () => { namespace: 'gallery', }); expect(mocks.notificationsAdd).not.toHaveBeenCalled(); + // The board may be what refused it: the listing, boards included, is read again before the next upload. + expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1); }); - it('trusts a board id the loaded list does not name', async () => { + it('sends an upload to a board the loaded list does not name once the ensured listing takes it', async () => { mocks.uploadGalleryImage.mockResolvedValue(imageUpload('photo.png', '2026-07-30T12:00:04.000Z', 'board-9')); selectedBoardId = 'board-9'; @@ -394,6 +404,8 @@ describe('focused gallery upload action', () => { await uploadFilesRef.current?.([new File(['image'], 'photo.png', { type: 'image/png' })]); }); + expect(mocks.ensureGalleryBoardDestinations).toHaveBeenCalledOnce(); + expect(mocks.canAddToCachedGalleryBoard).toHaveBeenCalledWith('board-9'); expect(mocks.uploadGalleryImage).toHaveBeenCalledExactlyOnceWith(expect.any(File), 'board-9', expect.anything()); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadAction.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadAction.ts index c7573394e72..88b867070e4 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadAction.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadAction.ts @@ -1,14 +1,26 @@ import type { GalleryItem } from '@features/gallery/core/items'; import type { GalleryBoard, GalleryView } from '@features/gallery/core/types'; +import { canOnBoard } from '@features/gallery/core/boardAccess'; import { getGalleryBoardLabel } from '@features/gallery/core/boardLabels'; import { classifyGalleryUpload, compareGalleryItems, legacyGeneratedImageToGalleryItem, } from '@features/gallery/core/items'; -import { isDateBoardId, uploadGalleryImage, uploadGalleryVideo } from '@features/gallery/data/backend'; -import { invalidateGallery } from '@features/gallery/data/queryCache'; +import { + isDateBoardId, + isInvalidGalleryBoardDestination, + uploadGalleryImage, + uploadGalleryVideo, +} from '@features/gallery/data/backend'; +import { + canAddToCachedGalleryBoard, + ensureGalleryBoardDestinations, + getCachedGalleryBoard, + invalidateGallery, + invalidateGalleryBoardDestinations, +} from '@features/gallery/data/queryCache'; import { createLogger } from '@platform/logging/logger'; import { assertAccountScopeCurrent, @@ -25,6 +37,8 @@ import type { GalleryActions } from './GalleryWidgetContext'; import { useGalleryHost, useOptionalGalleryUi } from './GalleryUiContext'; const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +/** A real board's id: not Uncategorized and not a virtual board, which hold no uploads. */ +const isGalleryBoardId = (boardId: string): boolean => boardId !== 'none' && !isInvalidGalleryBoardDestination(boardId); const uploadLogger = createLogger({ area: 'upload', namespace: 'gallery' }); export const useGalleryUploadAction = ({ @@ -38,7 +52,11 @@ export const useGalleryUploadAction = ({ * the selection alone, as hosts without a workbench must. */ getCurrentGalleryLocation?: () => { galleryView: GalleryView; selectedBoardId: string }; - /** A getter is read when the upload starts, for hosts that must not re-render on board changes. */ + /** + * A getter is read when the upload starts and, when it names a real board, once more after the listing of every + * readable board is ensured, the second reading deciding: for hosts that resolve the board from that listing or + * must not re-render on board changes. + */ selectedBoardId: string | (() => string); }): GalleryActions['uploadFiles'] => { const { notifications } = useGalleryHost(); @@ -49,8 +67,20 @@ export const useGalleryUploadAction = ({ return useCallback( async (files) => { const owner = captureAccountScope(); - const selectedBoardId = + const readSelectedBoardId = () => typeof selectedBoardIdOrGetter === 'function' ? selectedBoardIdOrGetter() : selectedBoardIdOrGetter; + let selectedBoardId = readSelectedBoardId(); + + // A board is judged from the listing of every readable board, never trusted as given: an upload to a board + // that was deleted, archived out of reach or shared read-only since the panel listed it would only fail. + // Uncategorized and virtual boards need no listing. + if (isGalleryBoardId(selectedBoardId)) { + await ensureGalleryBoardDestinations(queryClient); + if (!isAccountScopeCurrent(owner)) { + return []; + } + selectedBoardId = readSelectedBoardId(); + } if (isDateBoardId(selectedBoardId)) { notifications.reportError({ @@ -81,15 +111,35 @@ export const useGalleryUploadAction = ({ const targetBoardName = selectedBoard ? getGalleryBoardLabel(selectedBoard, t) : t('widgets.gallery.uncategorized'); + + // Refused before any upload: the server would refuse each file, leaving nothing but errors. The reason comes + // from the listing that decided, not the panel's older view of the board. The listing is read again at once, + // so the panel stops offering the board and the next upload is judged afresh. + if (isGalleryBoardId(selectedBoardId) && !canAddToCachedGalleryBoard(selectedBoardId, queryClient)) { + const cachedBoard = getCachedGalleryBoard(selectedBoardId, queryClient); + const cachedBoardName = cachedBoard ? getGalleryBoardLabel(cachedBoard, t) : targetBoardName; + + notifications.reportError({ + area: 'gallery-upload', + message: + cachedBoard && !canOnBoard(cachedBoard, 'add_media') + ? t('widgets.gallery.uploadsUnavailableForViewOnlyBoard', { name: cachedBoardName }) + : cachedBoard || selectedBoard + ? t('widgets.gallery.uploadsUnavailableForBoard', { name: cachedBoardName }) + : t('widgets.gallery.uploadsUnavailableForUnlistedBoard'), + namespace: 'gallery', + }); + void invalidateGalleryBoardDestinations(queryClient, owner); + return []; + } const formatImageCount = (count: number) => t('widgets.gallery.imageCount', { count }); const formatVideoCount = (count: number) => t('widgets.gallery.videoCount', { count }); const recordError = (error: unknown) => notifications.reportError({ area: 'gallery-actions', message: toErrorMessage(error), namespace: 'gallery' }); try { - // Only a board known to be virtual is demoted; an id the (possibly - // narrower or still-loading) list does not name is trusted as given. - const targetBoardId = selectedBoard && selectedBoard.kind !== 'board' ? 'none' : selectedBoardId; + // A virtual board holds no uploads; a real board was judged above. + const targetBoardId = isGalleryBoardId(selectedBoardId) ? selectedBoardId : 'none'; const imageUploads = accepted.filter((upload) => upload.kind === 'image'); const videoUploads = accepted.filter((upload) => upload.kind === 'video'); const imageResultsPromise = Promise.allSettled( @@ -147,6 +197,10 @@ export const useGalleryUploadAction = ({ }); } + // What landed is listed; a board that refused a file leaves the listing's view of it stale. Either way the + // gallery, boards included, reads again. + void invalidateGallery(queryClient); + if (uploadedItems.length === 0) { const fallback = t('widgets.gallery.uploadFailed', { failed: failedCount }); @@ -178,8 +232,6 @@ export const useGalleryUploadAction = ({ gallery?.selectItem(newestVisibleUpload); } - void invalidateGallery(queryClient); - const summary = t('widgets.gallery.uploadSummary', { board: targetBoardName, failed: failedCount, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadInput.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadInput.ts index 11104a3163a..5f2ecf73507 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadInput.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryUploadInput.ts @@ -1,13 +1,18 @@ import { getGalleryUploadAccept } from '@features/gallery/core/items'; import { useCallback, useRef, type ChangeEvent } from 'react'; -const ACCEPTED_UPLOAD_EXTENSIONS = getGalleryUploadAccept(['image', 'video']); +const ALL_UPLOAD_KINDS = ['image', 'video'] as const; const UPLOAD_INPUT_STYLE = { display: 'none' } as const; -/** The hidden-input picker every gallery upload trigger shares: change extracts files, resets the input (so re-picking the same file fires), and forwards. */ +/** + * The hidden-input picker every gallery upload trigger shares: change extracts files, resets the input (so re-picking + * the same file fires), and forwards. The default `accept` is computed per call, not at module scope: this module is + * bundled in a lazy chunk, and a module-scope call into a core bundled elsewhere runs before that core has + * initialised whenever the chunks come to import each other. + */ export const useGalleryUploadInput = ( onUploadFiles: (files: File[]) => Promise | void, - { accept = ACCEPTED_UPLOAD_EXTENSIONS, multiple = true }: { accept?: string; multiple?: boolean } = {} + { accept = getGalleryUploadAccept(ALL_UPLOAD_KINDS), multiple = true }: { accept?: string; multiple?: boolean } = {} ) => { const inputRef = useRef(null); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx index 534ee37694e..225add43596 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx @@ -162,7 +162,7 @@ const createStableGroups = () => ({ CanvasRenderSize: () => null, account: { currentUserId: null, multiuserEnabled: false }, capabilities: { canManagePromptTemplates: false, canManageSharedSystemPrompts: false }, - gallery: { findImage: noop, selectedImage: null, touchImages: noop, uploadBoardId: 'none' }, + gallery: { findImage: noop, getUploadBoardId: () => Promise.resolve('none'), selectedImage: null, touchImages: noop }, models: { ModelSelect: MainModelPicker, catalog: CATALOG, diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx index 98970c383f0..7afd6d613f1 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx @@ -100,8 +100,12 @@ export interface GenerationUiAdapter { findImage(imageName: string): void; selectedImage: GenerationSelectedImage | null; touchImages(): void; - /** The Gallery's auto-add board, where uploads made from Generate land; `none` is Uncategorized. */ - uploadBoardId: string; + /** + * Where an upload made from Generate lands, resolved when the upload starts from the listing of every readable + * board: the Gallery's auto-add board while that listing lets the account add to it, else the project's inbox; + * `none` is Uncategorized. + */ + getUploadBoardId(): Promise; }; models: { ModelSelect: ComponentType; diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx index 215b0789944..268986e2295 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx @@ -503,9 +503,12 @@ export const ReferenceImageCropDialog = ({ setIsSaving(true); try { - const file = await exportCroppedReferenceImage(image, cropBox, owner); + const [file, boardId] = await Promise.all([ + exportCroppedReferenceImage(image, cropBox, owner), + gallery.getUploadBoardId(), + ]); assertAccountScopeCurrent(owner); - await galleryTransfers.upload(file, gallery.uploadBoardId, { isIntermediate: false, signal: owner.signal }); + await galleryTransfers.upload(file, boardId, { isIntermediate: false, signal: owner.signal }); assertAccountScopeCurrent(owner); gallery.touchImages(); notifications.info(t('widgets.generate.cropSavedToAssets')); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx index 156c2152b9d..695c374ce6e 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx @@ -46,7 +46,7 @@ let root: Root | null = null; const noop = vi.fn(); const ADAPTER = { - gallery: { touchImages: vi.fn(), uploadBoardId: 'board-7' }, + gallery: { getUploadBoardId: () => Promise.resolve('board-7'), touchImages: vi.fn() }, notifications: { info: vi.fn(), reportError: vi.fn() }, } as unknown as GenerationUiAdapter; diff --git a/invokeai/frontend/webv2/src/features/intermediates/core/types.ts b/invokeai/frontend/webv2/src/features/intermediates/core/types.ts index f3d925e8e5b..b74bffbf43b 100644 --- a/invokeai/frontend/webv2/src/features/intermediates/core/types.ts +++ b/invokeai/frontend/webv2/src/features/intermediates/core/types.ts @@ -95,10 +95,16 @@ export interface IntermediatesImpact { } /** - * What names the media: a saved project or library workflow, the legacy editor's persisted state (`ownerId` is its - * state key), or a project set aside for repair by an earlier migration. + * What names the media: a saved project, one account's workspace in a project (`ownerId` is the project's id), a + * library workflow, the legacy editor's persisted state (`ownerId` is its state key), or a project set aside for + * repair by an earlier migration. */ -export type IntermediatesAffectedDocumentKind = 'project' | 'workflow' | 'client_state' | 'quarantined_project'; +export type IntermediatesAffectedDocumentKind = + | 'project' + | 'project_workspace' + | 'workflow' + | 'client_state' + | 'quarantined_project'; export interface IntermediatesAffectedDocument { kind: IntermediatesAffectedDocumentKind; diff --git a/invokeai/frontend/webv2/src/features/intermediates/ui/ClearDialog.tsx b/invokeai/frontend/webv2/src/features/intermediates/ui/ClearDialog.tsx index bead710594d..f9ed4c8125d 100644 --- a/invokeai/frontend/webv2/src/features/intermediates/ui/ClearDialog.tsx +++ b/invokeai/frontend/webv2/src/features/intermediates/ui/ClearDialog.tsx @@ -17,6 +17,7 @@ const CONFIRM_WORD = 'DELETE'; const AFFECTED_DOCUMENT_LABELS = { client_state: 'intermediates.dialog.affectedClientState', project: 'intermediates.dialog.affectedProject', + project_workspace: 'intermediates.dialog.affectedProjectWorkspace', quarantined_project: 'intermediates.dialog.affectedQuarantinedProject', workflow: 'intermediates.dialog.affectedWorkflow', } as const satisfies Record; diff --git a/invokeai/frontend/webv2/src/features/intermediates/ui/IntermediatesManager.browser.test.tsx b/invokeai/frontend/webv2/src/features/intermediates/ui/IntermediatesManager.browser.test.tsx index 02e7bebd4b0..906c1c2246b 100644 --- a/invokeai/frontend/webv2/src/features/intermediates/ui/IntermediatesManager.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/intermediates/ui/IntermediatesManager.browser.test.tsx @@ -88,6 +88,7 @@ const forceAffectedDocuments: IntermediatesPreview['affectedDocuments'] = [ { ...documentOf('alice'), kind: 'project', name: 'Portraits', ownerId: 'p1', references: 2 }, { ...documentOf('alice'), kind: 'client_state', name: null, ownerId: 'canvas', references: 1 }, { ...documentOf('alice'), kind: 'quarantined_project', name: 'Old sketch', ownerId: 'q1', references: 1 }, + { ...documentOf('alice'), kind: 'project_workspace', name: 'Portraits', ownerId: 'p1', references: 3 }, { ...documentOf('bob'), kind: 'client_state', name: null, ownerId: 'canvas', references: 1 }, ]; @@ -531,6 +532,7 @@ describe('IntermediatesManager', () => { const { activeOperationStore } = await import('@features/intermediates/data/operationStore'); const dialog = await openForceDialogReadyToConfirm(); expect(dialog.textContent).toContain('intermediates.dialog.affectedQuarantinedProject(count=1,name=Old sketch)'); + expect(dialog.textContent).toContain('intermediates.dialog.affectedProjectWorkspace(count=3,name=Portraits)'); expect(dialog.textContent).toContain('intermediates.dialog.forceWarningOwn'); dependencies.startIntermediatesOperation.mockRejectedValueOnce(new ApiError('Server error', 500)); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index c4bb98c7c5d..6277aedc8b6 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -94,6 +94,7 @@ const createTestCommands = (overrides: Partial = {}): Queu setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + refreshBoardDestinations: vi.fn(), reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), @@ -390,6 +391,7 @@ describe('queue runtime', () => { setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + refreshBoardDestinations: vi.fn(), reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), @@ -510,6 +512,45 @@ describe('queue runtime', () => { await runtime.dispose(); }); + it.each([ + ['generate', 'gallery', 1], + ['generate', 'canvas', 0], + // A workflow names boards on its nodes whatever its destination. + ['workflow', 'canvas', 1], + ] as const)( + 'reads the board listing again when a %s run bound for the %s fails, so a board that refused it leaves it', + async (sourceId, destination, refreshes) => { + vi.useFakeTimers(); + const queueItem = createPendingQueueItem(); + queueItem.snapshot.sourceId = sourceId; + queueItem.snapshot.destination = destination; + const project = { id: 'project-1', queue: { items: [queueItem] } }; + const commands = createTestCommands(); + const runtime = createQueueRuntime({ + ...runtimeServices, + backend: createTestBackend(), + ensureProjectPersisted: vi.fn().mockResolvedValue('refused'), + history: { + commands, + getSnapshot: () => ({ connectionStatus: 'connected', isHydrated: true, projects: [project] }), + subscribe: vi.fn(() => vi.fn()), + }, + journal: createTestJournal(), + }); + + runtime.start(); + await vi.advanceTimersByTimeAsync(1); + + expect(commands.setStatus).toHaveBeenCalledWith( + expect.objectContaining({ queueItemId: queueItem.id, status: 'failed' }) + ); + expect(commands.refreshBoardDestinations).toHaveBeenCalledTimes(refreshes); + expect(commands.refreshBackendData).not.toHaveBeenCalled(); + await runtime.dispose(); + vi.useRealTimers(); + } + ); + it.each([ ['retry', false], ['refused', true], @@ -2347,6 +2388,7 @@ describe('queue runtime video board routing', () => { setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + refreshBoardDestinations: vi.fn(), reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 0729d5b8429..71e1ef4a634 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -101,6 +101,8 @@ export interface QueueHistoryCommands { }): void; recordError(payload: { area: string; message: string; namespace: 'queue'; projectId?: string }): void; refreshBackendData(): void; + /** A board may have refused a run: read the listing results are routed by again, at once. */ + refreshBoardDestinations(): void; /** Reopen a finished run around the backend items re-running its failed or canceled ones; refused runs stay. */ reopenForRetry(payload: { projectId: string; @@ -435,7 +437,20 @@ export const createQueueRuntime = ({ viewerUserId?: string | null; }): QueueRuntime => { const owner = captureAccountScope(); - const commands = history.commands; + const commands: QueueHistoryCommands = { + ...history.commands, + setStatus: (payload) => { + history.commands.setStatus(payload); + // A run bound for the gallery, or a workflow run, whose nodes name boards whatever its destination, may have + // failed on a board: deleted, archived, or no longer the caller's to add to. The listing is read again at once; + // a run submitted after that read lands is routed by it, one submitted before still goes by the old listing. + const failedSnapshot = + payload.status === 'failed' ? getRouteTarget(history, payload.queueItemId)?.queueItem?.snapshot : undefined; + if (failedSnapshot && (failedSnapshot.destination === 'gallery' || failedSnapshot.sourceId === 'workflow')) { + history.commands.refreshBoardDestinations(); + } + }, + }; const startedRunKeys = new Set(); const reconcileRetryRunKeys = new Set(); const detachers: Array<() => void> = []; diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx index c0300125672..d1c6b0fb3b3 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx @@ -30,6 +30,7 @@ import { captureAccountScope, isAccountScopeCurrent, } from '@platform/state/accountLifecycle'; +import { ApiError } from '@platform/transport/http'; import { Button, IconButton } from '@platform/ui/Button'; import { DropTargetOverlay } from '@platform/ui/DropTargetOverlay'; import { DropZone } from '@platform/ui/DropZone'; @@ -377,7 +378,7 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({ targetArea: number | null; }) { const { t } = useTranslation(); - const { getUploadBoardId, reportError, touchGalleryImages } = useVideoUiActions(); + const { getUploadBoardId, refreshBoardDestinations, reportError, touchGalleryImages } = useVideoUiActions(); const imageInputRef = useRef(null); const videoInputRef = useRef(null); const [isLoading, setIsLoading] = useState(false); @@ -560,13 +561,16 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({ setIsLoading(true); try { + const boardId = await getUploadBoardId(); + + assertAccountScopeCurrent(owner); if (kind === 'video') { - const uploaded = await galleryTransfers.uploadVideo(file, getUploadBoardId(), { signal: owner.signal }); + const uploaded = await galleryTransfers.uploadVideo(file, boardId, { signal: owner.signal }); assertAccountScopeCurrent(owner); await addVideoReference(uploaded.name); } else { - const uploaded = await galleryTransfers.upload(file, getUploadBoardId(), { signal: owner.signal }); + const uploaded = await galleryTransfers.upload(file, boardId, { signal: owner.signal }); assertAccountScopeCurrent(owner); addImageReference({ height: uploaded.height, name: uploaded.imageName, width: uploaded.width }); @@ -577,6 +581,11 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({ return; } + // The board refused the upload (403) or is gone (404): the listing it was chosen from is read again at once, + // so an upload that follows the read is routed by it. + if (error instanceof ApiError && (error.status === 403 || error.status === 404)) { + refreshBoardDestinations(); + } const message = error instanceof Error ? error.message : String(error); setErrorMessage(message); reportError(message); @@ -584,7 +593,7 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({ setIsLoading(false); } }, - [addImageReference, addVideoReference, getUploadBoardId, reportError, touchGalleryImages] + [addImageReference, addVideoReference, getUploadBoardId, refreshBoardDestinations, reportError, touchGalleryImages] ); const handleImageFileChange = useCallback( diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoUiContext.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoUiContext.tsx index bcee0ca8cb8..58fc71b08c1 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoUiContext.tsx @@ -8,8 +8,12 @@ import { createContext, use, useMemo } from 'react'; export interface VideoUiAdapter { /** Reveal this media's board, page, and cell in Gallery and show Preview. */ findInGallery(ref: GalleryItemRef): void; - /** Read the selected upload board at upload time without subscribing to board changes. */ - getUploadBoardId(): string; + /** + * Where an upload made from the panel lands, resolved when the upload starts from the listing of every readable + * board: the Gallery's auto-add board while that listing lets the account add to it, else the project's inbox; + * `none` is Uncategorized. + */ + getUploadBoardId(): Promise; /** Functional patches run synchronously against the originating project's latest normalized values. */ patchValues( values: Partial | ((current: VideoWidgetValues) => Partial), @@ -24,6 +28,8 @@ export interface VideoUiAdapter { rawValues: Record; /** Read this project's raw panel values at call time without subscribing, for writes that complete asynchronously. */ readValues(): Record; + /** A board refused an upload or is gone: read the listing uploads are routed by again, at once. */ + refreshBoardDestinations(): void; reportError(message: string): void; showPromptSyntaxHighlighting: boolean; touchGalleryImages(): void; @@ -54,6 +60,7 @@ export type VideoUiActions = Pick< | 'patchValues' | 'playVideoSpanInPreview' | 'readValues' + | 'refreshBoardDestinations' | 'reportError' | 'touchGalleryImages' | 'videoSpanPlayback' @@ -70,6 +77,7 @@ export const VideoUiProvider = ({ adapter, children }: { adapter: VideoUiAdapter patchValues, playVideoSpanInPreview, readValues, + refreshBoardDestinations, reportError, touchGalleryImages, videoSpanPlayback, @@ -81,6 +89,7 @@ export const VideoUiProvider = ({ adapter, children }: { adapter: VideoUiAdapter patchValues, playVideoSpanInPreview, readValues, + refreshBoardDestinations, reportError, touchGalleryImages, videoSpanPlayback, @@ -91,6 +100,7 @@ export const VideoUiProvider = ({ adapter, children }: { adapter: VideoUiAdapter patchValues, playVideoSpanInPreview, readValues, + refreshBoardDestinations, reportError, touchGalleryImages, videoSpanPlayback, diff --git a/invokeai/frontend/webv2/src/features/video/ui/referenceConditioningDefault.browser.test.tsx b/invokeai/frontend/webv2/src/features/video/ui/referenceConditioningDefault.browser.test.tsx index 077e031d155..e1f6ab43b7d 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/referenceConditioningDefault.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/referenceConditioningDefault.browser.test.tsx @@ -55,12 +55,13 @@ const videoItem = (mediaOrigin?: string): GalleryVideoItem => ({ const adapter = { findInGallery: () => undefined, - getUploadBoardId: () => 'none', + getUploadBoardId: () => Promise.resolve('none'), patchValues: (_values: Partial) => undefined, playVideoSpanInPreview: () => null, projectId: 'project-1', rawValues: {}, readValues: () => ({}), + refreshBoardDestinations: () => undefined, reportError: () => undefined, showPromptSyntaxHighlighting: false, touchGalleryImages: () => undefined, diff --git a/invokeai/frontend/webv2/src/features/video/ui/videoConditioningClipRole.browser.test.tsx b/invokeai/frontend/webv2/src/features/video/ui/videoConditioningClipRole.browser.test.tsx index cf16c479edd..7648055b609 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/videoConditioningClipRole.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/videoConditioningClipRole.browser.test.tsx @@ -53,7 +53,7 @@ const galleryAdapter = { /** The field reaches for find-in-gallery on render; nothing here exercises it. */ const videoAdapter = { findInGallery: noop, - getUploadBoardId: () => 'none', + getUploadBoardId: () => Promise.resolve('none'), patchValues: noop, playVideoSpanInPreview: () => null, projectId: 'project-1', @@ -61,6 +61,7 @@ const videoAdapter = { readValues: () => ({}), reportError: noop, showPromptSyntaxHighlighting: false, + refreshBoardDestinations: noop, touchGalleryImages: noop, videoSpanPlayback: { getState: () => null, subscribe: () => () => undefined }, }; diff --git a/invokeai/frontend/webv2/src/features/video/ui/videoConditioningFindInGallery.browser.test.tsx b/invokeai/frontend/webv2/src/features/video/ui/videoConditioningFindInGallery.browser.test.tsx index d0a25481d44..5b614ded1fb 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/videoConditioningFindInGallery.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/videoConditioningFindInGallery.browser.test.tsx @@ -59,10 +59,11 @@ let queryClient: QueryClient; const findInGallery = vi.fn(); const videoAdapter = { findInGallery, - getUploadBoardId: () => 'none', + getUploadBoardId: () => Promise.resolve('none'), patchValues: vi.fn(), playVideoSpanInPreview: vi.fn(), reportError: vi.fn(), + refreshBoardDestinations: vi.fn(), touchGalleryImages: vi.fn(), videoSpanPlayback: { getState: () => null, subscribe: () => () => undefined }, } as unknown as VideoUiAdapter; diff --git a/invokeai/frontend/webv2/src/features/video/ui/videoReferenceFindInGallery.browser.test.tsx b/invokeai/frontend/webv2/src/features/video/ui/videoReferenceFindInGallery.browser.test.tsx index c176eb94f07..33647034ecb 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/videoReferenceFindInGallery.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/videoReferenceFindInGallery.browser.test.tsx @@ -56,10 +56,11 @@ let root: Root; const findInGallery = vi.fn(); const adapter = { findInGallery, - getUploadBoardId: () => 'none', + getUploadBoardId: () => Promise.resolve('none'), patchValues: vi.fn(), playVideoSpanInPreview: vi.fn(), reportError: vi.fn(), + refreshBoardDestinations: vi.fn(), touchGalleryImages: vi.fn(), videoSpanPlayback: { getState: () => null, subscribe: () => () => undefined }, } as unknown as VideoUiAdapter; diff --git a/invokeai/frontend/webv2/src/features/video/ui/videoReferencePlaySpan.browser.test.tsx b/invokeai/frontend/webv2/src/features/video/ui/videoReferencePlaySpan.browser.test.tsx index 04bb4021d39..44eabfee551 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/videoReferencePlaySpan.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/videoReferencePlaySpan.browser.test.tsx @@ -88,10 +88,11 @@ const pausePlayback = vi.fn(() => { }); const adapter = { - getUploadBoardId: () => 'none', + getUploadBoardId: () => Promise.resolve('none'), patchValues: vi.fn(), playVideoSpanInPreview, reportError, + refreshBoardDestinations: vi.fn(), touchGalleryImages: vi.fn(), videoSpanPlayback: { getState: () => playbackState, diff --git a/invokeai/frontend/webv2/src/features/video/ui/videoReferencePromptLabels.browser.test.tsx b/invokeai/frontend/webv2/src/features/video/ui/videoReferencePromptLabels.browser.test.tsx index bd778f12152..527747618b8 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/videoReferencePromptLabels.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/videoReferencePromptLabels.browser.test.tsx @@ -56,10 +56,11 @@ let root: Root; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const adapter = { - getUploadBoardId: () => 'none', + getUploadBoardId: () => Promise.resolve('none'), patchValues: vi.fn(), playVideoSpanInPreview: vi.fn(), reportError: vi.fn(), + refreshBoardDestinations: vi.fn(), touchGalleryImages: vi.fn(), videoSpanPlayback: { getState: () => null, subscribe: () => () => undefined }, } as unknown as VideoUiAdapter; diff --git a/invokeai/frontend/webv2/src/features/video/ui/videoReferenceReorderFocus.browser.test.tsx b/invokeai/frontend/webv2/src/features/video/ui/videoReferenceReorderFocus.browser.test.tsx index 29805d79b80..3597240e041 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/videoReferenceReorderFocus.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/videoReferenceReorderFocus.browser.test.tsx @@ -46,9 +46,10 @@ let root: Root; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const adapter = { - getUploadBoardId: () => 'none', + getUploadBoardId: () => Promise.resolve('none'), patchValues: vi.fn(), reportError: vi.fn(), + refreshBoardDestinations: vi.fn(), touchGalleryImages: vi.fn(), videoSpanPlayback: { getState: () => null, subscribe: () => () => undefined }, } as unknown as VideoUiAdapter; diff --git a/invokeai/frontend/webv2/src/features/video/ui/videoReferenceUploadRefusal.browser.test.tsx b/invokeai/frontend/webv2/src/features/video/ui/videoReferenceUploadRefusal.browser.test.tsx new file mode 100644 index 00000000000..9ef5fd744c3 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/video/ui/videoReferenceUploadRefusal.browser.test.tsx @@ -0,0 +1,131 @@ +/* oxlint-disable react-perf/jsx-no-new-function-as-prop */ +import type * as GalleryModule from '@features/gallery'; +import type { VideoReferenceItem, VideoWidgetValues } from '@features/video/core/types'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { DndContext } from '@dnd-kit/core'; +import { VideoUiProvider } from '@features/video/ui/VideoUiContext'; +import { ApiError } from '@platform/transport/http'; +import { system } from '@theme/system'; +import { createInstance } from 'i18next'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { VideoReferenceListField } from './VideoReferenceListField'; + +/** + * An upload the board refused leaves the listing the board was chosen from stale: that listing is read again at once + * so an upload that follows the read is routed by it. Other failures leave the listing alone. + */ + +const galleryMocks = vi.hoisted(() => ({ upload: vi.fn() })); + +vi.mock('@features/gallery', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, galleryTransfers: { ...actual.galleryTransfers, upload: galleryMocks.upload } }; +}); + +const i18n = createInstance(); + +void i18n.use(initReactI18next).init({ fallbackLng: 'en', initAsync: false, lng: 'en', resources: { en: {} } }); + +const getUploadBoardId = vi.fn(() => Promise.resolve('board-1')); +const refreshBoardDestinations = vi.fn(); +const reportError = vi.fn(); +const touchGalleryImages = vi.fn(); +const adapter = { + findInGallery: () => undefined, + getUploadBoardId, + patchValues: (_values: Partial) => undefined, + playVideoSpanInPreview: () => null, + projectId: 'project-1', + rawValues: {}, + readValues: () => ({}), + refreshBoardDestinations, + reportError, + showPromptSyntaxHighlighting: false, + touchGalleryImages, + videoSpanPlayback: { getState: () => null, subscribe: () => () => undefined }, +}; + +const NO_REFERENCES: VideoReferenceItem[] = []; + +let container: HTMLDivElement; +let root: Root; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +/** Uploads one image file through the list's own picker. */ +const uploadImage = async (): Promise => { + act(() => { + root.render( + + + + + undefined} + /> + + + + + ); + }); + + const imageInput = [...container.querySelectorAll('input[type="file"]')].find((input) => + input.accept.includes('image/png') + ); + + expect(imageInput).toBeDefined(); + + const transfer = new DataTransfer(); + + transfer.items.add(new File([new Uint8Array([0])], 'still.png', { type: 'image/png' })); + + await act(async () => { + Object.defineProperty(imageInput!, 'files', { configurable: true, value: transfer.files }); + imageInput!.dispatchEvent(new Event('change', { bubbles: true })); + await vi.waitFor(() => expect(reportError).toHaveBeenCalledTimes(1)); + }); +}; + +describe('an upload the board refuses', () => { + it.each([ + [ + 'refuses the board (403)', + new ApiError(JSON.stringify({ detail: 'Not authorized to change this board' }), 403), + 1, + ], + ['no longer has the board (404)', new ApiError(JSON.stringify({ detail: 'Board not found' }), 404), 1], + ['fails otherwise', new Error('upload failed'), 0], + ])('reads the board listing again only when the server %s', async (_case, error, refreshes) => { + galleryMocks.upload.mockRejectedValue(error); + + await uploadImage(); + + expect(galleryMocks.upload).toHaveBeenCalledWith(expect.any(File), 'board-1', expect.anything()); + expect(refreshBoardDestinations).toHaveBeenCalledTimes(refreshes); + expect(touchGalleryImages).not.toHaveBeenCalled(); + expect(reportError).toHaveBeenCalledWith(error.message); + }); +}); 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 fb616cb37d9..3282364946d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts @@ -437,6 +437,38 @@ describe('compileProjectGraph', () => { ).not.toHaveProperty('board'); }); + it('sends an explicit board the destination predicate refuses where Auto goes, and keeps one it allows', () => { + const { doc, sinkId } = buildDocument(); + const isBoardDestination = vi.fn((boardId: string) => boardId === 'kept'); + const withBoard = (value: unknown, autoBoardId: string | null) => + compileProjectGraph( + projectGraphReducer(doc, { fieldName: 'board', nodeId: sinkId, type: 'setFieldValue', value }), + templates, + { autoBoardId, isBoardDestination } + ).backendGraph?.nodes[sinkId]; + + expect(withBoard({ board_id: 'kept' }, 'auto-board')).toMatchObject({ board: { board_id: 'kept' } }); + // Revoked, archived away or deleted since it was chosen: the run lands on the auto-add board instead. + expect(withBoard({ board_id: 'revoked' }, 'auto-board')).toMatchObject({ board: { board_id: 'auto-board' } }); + // With no auto-add board the refused board resolves like Auto: unboarded, so Uncategorized. + expect(withBoard({ board_id: 'revoked' }, 'none')).not.toHaveProperty('board'); + // The sentinels never ask the predicate; without a predicate an explicit board is taken as given. + expect(withBoard('none', 'auto-board')).not.toHaveProperty('board'); + expect(isBoardDestination.mock.calls.map(([boardId]) => boardId)).toEqual(['kept', 'revoked', 'revoked']); + expect( + compileProjectGraph( + projectGraphReducer(doc, { + fieldName: 'board', + nodeId: sinkId, + type: 'setFieldValue', + value: { board_id: 'revoked' }, + }), + templates, + { autoBoardId: 'auto-board' } + ).backendGraph?.nodes[sinkId] + ).toMatchObject({ board: { board_id: 'revoked' } }); + }); + it('preserves auto/none board sentinels only in Call Saved Workflow inputs', () => { const callTemplate = template('call_saved_workflow', { workflow_id: input('workflow_id', { type: { batch: false, cardinality: 'SINGLE', name: 'StringField' } }), diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts index 3ed03b960b2..7a99f1ecb30 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts @@ -66,15 +66,36 @@ export interface CompileProjectGraphOptions { * backend saves as Uncategorized. */ autoBoardId?: string | null; + /** + * Whether results may land on a board a field names explicitly: a board the caller may add to now. A board the + * predicate refuses (deleted, archived away, or someone else's the caller can only view since choosing it) resolves + * like Auto, so the run lands somewhere rather than failing on its board after generating. Absent, every explicit + * board is taken as given. + */ + isBoardDestination?: (boardId: string) => boolean; } -/** None is Uncategorized; Auto takes the auto-add board, if there is one. */ -const resolveBoardSentinel = (value: unknown, autoBoardId: string | null | undefined): unknown => { +const getExplicitBoardId = (value: unknown): string | null => + typeof (value as { board_id?: unknown } | null)?.board_id === 'string' + ? (value as { board_id: string }).board_id + : null; + +/** None is Uncategorized; Auto, and an explicit board that is no destination any more, take the auto-add board. */ +const resolveBoardSentinel = ( + value: unknown, + { autoBoardId, isBoardDestination }: Pick +): unknown => { if (value === 'none') { return value; } - if (isEmptyValue(value) || value === 'auto') { + const explicitBoardId = getExplicitBoardId(value); + + if ( + isEmptyValue(value) || + value === 'auto' || + (explicitBoardId !== null && isBoardDestination !== undefined && !isBoardDestination(explicitBoardId)) + ) { return autoBoardId && autoBoardId !== 'none' ? { board_id: autoBoardId } : 'auto'; } @@ -244,13 +265,13 @@ export const getProjectGraphReadiness = ( const toGraphInputValue = ( inputTemplate: FieldInputTemplate, value: unknown, - options: { autoBoardId?: string | null; preserveBoardSentinel?: boolean } + options: CompileProjectGraphOptions & { preserveBoardSentinel?: boolean } ): unknown => { if (inputTemplate.type.name !== 'BoardField') { return value; } - const resolved = resolveBoardSentinel(value, options.autoBoardId); + const resolved = resolveBoardSentinel(value, options); // A saved workflow's own builder reads the sentinels; a direct field leaves an unresolved one out. return options.preserveBoardSentinel || (resolved !== 'auto' && resolved !== 'none') ? resolved : undefined; @@ -260,7 +281,7 @@ const toGraphInputValue = ( export const compileProjectGraph = ( document: ProjectGraphState, templates: InvocationTemplates, - { autoBoardId }: CompileProjectGraphOptions = {} + { autoBoardId, isBoardDestination }: CompileProjectGraphOptions = {} ): CompiledWorkflowGraph => { const forLoopError = validateForLoopGraph(document); @@ -316,6 +337,7 @@ export const compileProjectGraph = ( node.data.type === 'call_saved_workflow' && instance.name.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX); const value = toGraphInputValue(inputTemplate, instance.value, { autoBoardId, + isBoardDestination, preserveBoardSentinel: isSavedWorkflowInput, }); @@ -553,7 +575,7 @@ export interface WorkflowSubmissionPlanOptions extends CompileProjectGraphOption export const planWorkflowSubmission = ( document: ProjectGraphState, templates: InvocationTemplates, - { autoBoardId, batchCount, generators, random }: WorkflowSubmissionPlanOptions + { autoBoardId, batchCount, generators, isBoardDestination, random }: WorkflowSubmissionPlanOptions ): WorkflowSubmissionPlan | null => { const batchPlan = planWorkflowBatch(document, templates, { generators, random }); @@ -573,6 +595,9 @@ export const planWorkflowSubmission = ( batchCount, batchData: batchPlan.groups, batchSize: batchPlan.batchSize, - graph: applyWorkflowSeeds(compileProjectGraph(document, templates, { autoBoardId }), seedPlan.seeds), + graph: applyWorkflowSeeds( + compileProjectGraph(document, templates, { autoBoardId, isBoardDestination }), + seedPlan.seeds + ), }; }; 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 d13d75c529f..eca5bfd93e2 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts @@ -81,6 +81,14 @@ const createEdge = (source: string, target: string): WorkflowEdge => ({ type: 'default', }); +describe('workflow item ids', () => { + it('are UUIDs, so a lock or presence marker can name a node whoever created it', () => { + expect(buildInvocationNode(template, { x: 0, y: 0 }).id).toMatch( + /^add-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + }); +}); + describe('projectGraphReducer', () => { it('builds nodes from templates with default input values', () => { const node = buildInvocationNode(template, { x: 5, y: 6 }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.ts index f4720c456b5..b16f05ff36f 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.ts @@ -1,5 +1,7 @@ import type { SeedMode } from '@platform/core/seed'; +import { createItemId } from '@platform/browser/randomUuid'; + import type { ContainerFormElement, FieldIdentifier, @@ -35,8 +37,7 @@ import { isInvocationNode, isNotesNode } from './types'; const now = (): string => new Date().toISOString(); -export const createWorkflowId = (prefix: string): string => - `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +export const createWorkflowId = (prefix: string): string => createItemId(prefix); export const createWorkflowForm = (): WorkflowForm => { const rootElementId = createWorkflowId('container'); 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 42ff9c4c9c1..4f28ceeefd1 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 @@ -1,7 +1,9 @@ +/* oxlint-disable react-perf/jsx-no-new-object-as-prop */ import type { FieldInputTemplate } from '@features/workflow/contracts'; import { ChakraProvider, Field } from '@chakra-ui/react'; import { DndContext } from '@dnd-kit/core'; +import { GalleryUiProvider, type GalleryUiAdapter } from '@features/gallery/react'; import { getWorkflowFieldInvalidReason } from '@features/workflow/utility'; import { toaster } from '@platform/ui'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -55,8 +57,8 @@ vi.mock('@features/gallery/picker', () => ({ vi.mock('@features/gallery', () => ({ formatGalleryVideoDuration: (seconds: number) => `${seconds}s`, - galleryDestinations: { list: () => Promise.resolve([]) }, galleryItems: { resolve: (...args: unknown[]) => resolveItemMock(...args) }, + isGalleryBoardAttachable: (boardId: string) => boardId !== 'none' && !boardId.startsWith('by_date:'), galleryTransfers: { upload: (...args: unknown[]) => uploadImageMock(...args), uploadVideo: (...args: unknown[]) => uploadVideoMock(...args), @@ -945,6 +947,109 @@ describe('WorkflowFieldInput media inputs', () => { }); }); +describe('WorkflowFieldInput board field', () => { + const BOARD_TEMPLATE = { + name: 'board', + title: 'Board', + type: { cardinality: 'SINGLE', name: 'BoardField' }, + } as unknown as FieldInputTemplate; + const boardDto = (boardId: string, overrides: Record = {}) => ({ + admin_override: false, + allowed_actions: ['read', 'add_media', 'manage_items', 'rename', 'set_cover', 'archive', 'move', 'delete'], + archived: false, + asset_count: 0, + board_id: boardId, + board_name: boardId, + created_at: '2026-01-01T00:00:00.000Z', + image_count: 1, + is_inbox: false, + my_role: 'owner', + owner_username: 'Me', + project_id: null, + video_count: 0, + ...overrides, + }); + const listing = [ + boardDto('mine'), + boardDto('team', { allowed_actions: ['read', 'add_media'], my_role: 'contributor', owner_username: 'Ana' }), + boardDto('viewed', { allowed_actions: ['read'], my_role: 'viewer', owner_username: 'Ben' }), + ]; + const renderBoardField = async (boardSharing: boolean | undefined) => { + const adapter = { boardSharing } as unknown as GalleryUiAdapter; + await act(() => { + root.render( + + + + + + + + ); + }); + const trigger = host.querySelector('[data-scope="select"][data-part="trigger"]'); + if (!trigger) { + throw new Error('Board trigger not rendered'); + } + await vi.waitFor(() => { + expect(queryClient.getQueryCache().findAll({ queryKey: ['gallery', 'boards'] })[0]?.state.status).toBe('success'); + }); + await act(() => userEvent.click(trigger)); + + return Array.from(document.querySelectorAll('[data-scope="select"][data-part="item"]')).map((item) => + item.textContent?.trim() + ); + }; + + beforeEach(() => { + workflowApiMock.apiFetchJson.mockResolvedValue(listing); + }); + + it("offers the boards the caller may add to from the gallery's listing, naming other people's owners", async () => { + expect(await renderBoardField(true)).toEqual(['Auto', 'None', 'mine', 'team · Ana']); + }); + + it('folds every board into the caller’s own on a host without board sharing', async () => { + expect(await renderBoardField(false)).toEqual(['Auto', 'None', 'mine', 'team']); + }); + + it('sends an upload to the project inbox when the selected board is not in the live listing', async () => { + uploadImageMock.mockResolvedValue({ imageName: 'uploaded.png' }); + galleryValues.selectedBoardId = 'restricted'; + galleryValues.projectBoardId = 'inbox'; + workflowApiMock.apiFetchJson.mockResolvedValue([boardDto('inbox', { is_inbox: true, project_id: 'project-1' })]); + const onChange = vi.fn(); + const imageTemplate = { + name: 'image', + title: 'Image', + type: { name: 'ImageField' }, + } as unknown as FieldInputTemplate; + + try { + await renderField(imageTemplate, undefined, onChange); + await act(() => findButton('Upload').click()); + const fileInput = host.querySelector('input[type="file"]')!; + const transfer = new DataTransfer(); + transfer.items.add(new File(['data'], 'a.png', { type: 'image/png' })); + fileInput.files = transfer.files; + await act(() => fileInput.dispatchEvent(new Event('change', { bubbles: true }))); + + await vi.waitFor(() => { + expect(onChange).toHaveBeenCalledWith({ image_name: 'uploaded.png' }); + }); + // The listing was read before the destination was chosen; the board it lacks is no destination. + expect(workflowApiMock.apiFetchJson).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/boards/'), + expect.anything() + ); + expect(uploadImageMock.mock.calls[0]?.[1]).toBe('inbox'); + } finally { + delete galleryValues.selectedBoardId; + delete galleryValues.projectBoardId; + } + }); +}); + /** Feed committed values back through an owning parent to expose keystroke rerenders and blur clamping. */ const StatefulLoRAField = ({ initial, onCommit }: { initial: unknown; onCommit: (value: unknown) => void }) => { const [value, setValue] = useState(initial); 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 376836ee3bf..9deb042755d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx @@ -23,16 +23,26 @@ import { import { useDndContext, useDndMonitor, useDroppable, type DragEndEvent } from '@dnd-kit/core'; import { formatGalleryVideoDuration, - galleryDestinations, galleryItems, galleryTransfers, - type GalleryBoard, + isGalleryBoardAttachable, type GalleryItem, } from '@features/gallery'; -import { getSelectedGalleryImageFromValues, toGalleryItemKey } from '@features/gallery/contracts'; +import { + canOnBoard, + getSelectedGalleryImageFromValues, + isOwnGalleryBoard, + toGalleryItemKey, +} from '@features/gallery/contracts'; import { FindInGalleryThumbnailButton } from '@features/gallery/mediaSlot'; import { GalleryPickerPopover, type GalleryPickerSelection } from '@features/gallery/picker'; -import { invalidateGallery } from '@features/gallery/queries'; +import { + canAddToCachedGalleryBoard, + ensureGalleryBoardDestinations, + galleryBoardsOptions, + invalidateGallery, +} from '@features/gallery/queries'; +import { useHostGalleryBoards } from '@features/gallery/react'; import { galleryImageUrls, galleryVideoUrls } from '@features/gallery/utility'; import { DEFAULT_LORA_WEIGHT_CONFIG, sanitizeBatchCount, SCHEDULER_OPTIONS } from '@features/generation/settings'; import { isInvocationNode } from '@features/workflow/contracts'; @@ -82,13 +92,7 @@ import { } from '@features/workflow/utility'; import { planSeedSubmission, type SeedMode, wrapSeed } from '@platform/core/seed'; import { useMountEffect } from '@platform/react/useMountEffect'; -import { - assertAccountScopeCurrent, - captureAccountScope, - isAccountScopeCurrent, - registerAccountOwnedResource, - type AccountScope, -} from '@platform/state/accountLifecycle'; +import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { Button, ColorPicker, @@ -497,59 +501,16 @@ const SchedulerInput = ({ id, invalid, onChange, template, value }: WorkflowFiel /> ); -let boardOptionsRequest: { owner: AccountScope; promise: Promise } | null = null; - -registerAccountOwnedResource({ - clear: () => { - boardOptionsRequest = null; - }, - name: 'workflow-board-options', -}); - -const getBoardOptions = (): Promise => { - const owner = captureAccountScope(); - - if (boardOptionsRequest?.owner !== owner) { - const promise = galleryDestinations - .list({ signal: owner.signal }) - .then((loadedBoards) => { - assertAccountScopeCurrent(owner); - - return loadedBoards.filter((board) => board.kind === 'board'); - }) - .catch((error: unknown) => { - if (boardOptionsRequest?.promise === promise) { - boardOptionsRequest = null; - } - throw error; - }); - - boardOptionsRequest = { owner, promise }; - } - - return boardOptionsRequest.promise; -}; - const BoardInput = ({ id, invalid, onChange, template, value }: WorkflowFieldInputProps) => { - const [boards, setBoards] = useState([]); - - useEffect(() => { - let isCancelled = false; - - getBoardOptions() - .then((loadedBoards) => { - if (!isCancelled) { - setBoards(loadedBoards); - } - }) - .catch(() => { - // Board listing is a convenience; the auto/none sentinels still work. - }); - - return () => { - isCancelled = true; - }; - }, []); + // The gallery's own listing, so grants, revocations and new boards reach the field as they reach the panel. + const { data } = useQuery(galleryBoardsOptions()); + const listedBoards = useHostGalleryBoards(data); + // Only boards a node's results may land on: someone else's board the caller can only view would refuse them after + // the run. + const boards = useMemo( + () => listedBoards.filter((board) => board.kind === 'board' && canOnBoard(board, 'add_media')), + [listedBoards] + ); const selected = value === 'auto' || value === 'none' @@ -561,7 +522,10 @@ const BoardInput = ({ id, invalid, onChange, template, value }: WorkflowFieldInp () => [ { label: 'Auto', value: 'auto' }, { label: 'None', value: 'none' }, - ...boards.map((board) => ({ label: board.name, value: board.id })), + ...boards.map((board) => ({ + label: isOwnGalleryBoard(board) || !board.ownerName ? board.name : `${board.name} · ${board.ownerName}`, + value: board.id, + })), ], [boards] ); @@ -619,9 +583,12 @@ const useMediaUpload = ({ onUploaded: (names: string[]) => void; }) => { const config = MEDIA_FIELD_CONFIG[kind]; - const uploadBoardId = useWorkflowProjectSelector((project) => + const selectedBoardId = useWorkflowProjectSelector((project) => typeof project.galleryValues.selectedBoardId === 'string' ? project.galleryValues.selectedBoardId : 'none' ); + const projectBoardId = useWorkflowProjectSelector((project) => + typeof project.galleryValues.projectBoardId === 'string' ? project.galleryValues.projectBoardId : null + ); const { project } = useWorkflowUi(); const queryClient = useQueryClient(); const isMountedRef = useRef(true); @@ -663,6 +630,21 @@ const useMediaUpload = ({ const names: string[] = []; const failedFiles: string[] = []; + // Uploads follow the gallery's selected board, unless it takes none: a date board, or someone else's board + // the caller can only view or no longer read. Then they go to the project's inbox, where results go in the + // same case. The listing is ensured first, so the board is judged from one and never trusted unseen. + if (selectedBoardId !== 'none') { + await ensureGalleryBoardDestinations(queryClient); + } + if (!isAccountScopeCurrent(owner)) { + return; + } + const uploadBoardId = + selectedBoardId === 'none' || + (isGalleryBoardAttachable(selectedBoardId) && canAddToCachedGalleryBoard(selectedBoardId, queryClient)) + ? selectedBoardId + : (projectBoardId ?? 'none'); + // Each file lands or fails on its own; what landed is adopted either way. for (const file of files) { try { @@ -684,9 +666,11 @@ const useMediaUpload = ({ return; } - if (names.length > 0) { - void invalidateGallery(queryClient, owner); + // What landed is listed; a board that refused a file leaves the listing's view of it stale. Either way the + // gallery reads again. + void invalidateGallery(queryClient, owner); + if (names.length > 0) { if (isMountedRef.current && project.getSnapshot().id === projectId) { onUploaded(names); } else { @@ -711,7 +695,7 @@ const useMediaUpload = ({ } })(); }, - [config.noun, kind, multiple, onUploaded, project, queryClient, uploadBoardId] + [config.noun, kind, multiple, onUploaded, project, projectBoardId, queryClient, selectedBoardId] ); return { fileInputRef, isUploading, onFileChange, onUploadClick }; diff --git a/invokeai/frontend/webv2/src/platform/browser/randomUuid.test.ts b/invokeai/frontend/webv2/src/platform/browser/randomUuid.test.ts index 6e2a6e053f4..bf853fbc842 100644 --- a/invokeai/frontend/webv2/src/platform/browser/randomUuid.test.ts +++ b/invokeai/frontend/webv2/src/platform/browser/randomUuid.test.ts @@ -1,4 +1,4 @@ -import { createUuid, createUuidWith, type UuidCryptoSource } from '@platform/browser/randomUuid'; +import { createItemId, createUuid, createUuidWith, type UuidCryptoSource } from '@platform/browser/randomUuid'; import { describe, expect, it, vi } from 'vitest'; const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; @@ -68,3 +68,13 @@ describe('createUuid', () => { } }); }); + +describe('createItemId', () => { + it('keeps the prefix and adds a fresh UUID', () => { + const [first, second] = [createItemId('layer'), createItemId('layer')]; + + expect(first.slice(0, 'layer-'.length)).toBe('layer-'); + expect(first.slice('layer-'.length)).toMatch(UUID_V4); + expect(first).not.toBe(second); + }); +}); diff --git a/invokeai/frontend/webv2/src/platform/browser/randomUuid.ts b/invokeai/frontend/webv2/src/platform/browser/randomUuid.ts index 6b58b6dd321..406de9c9885 100644 --- a/invokeai/frontend/webv2/src/platform/browser/randomUuid.ts +++ b/invokeai/frontend/webv2/src/platform/browser/randomUuid.ts @@ -22,3 +22,6 @@ export const createUuidWith = (source: UuidCryptoSource): string => { }; export const createUuid = (): string => createUuidWith(globalThis.crypto); + +/** `-`: an id unique without coordination, whichever client mints it. */ +export const createItemId = (prefix: string): string => `${prefix}-${createUuid()}`; diff --git a/invokeai/frontend/webv2/src/platform/transport/http.test.ts b/invokeai/frontend/webv2/src/platform/transport/http.test.ts index 6fb73c502a9..304f4625f45 100644 --- a/invokeai/frontend/webv2/src/platform/transport/http.test.ts +++ b/invokeai/frontend/webv2/src/platform/transport/http.test.ts @@ -16,6 +16,8 @@ import { apiFetchRaw, buildApiUrl, configureHttpAuth, + getApiErrorCode, + getApiErrorMessage, getBackendSocketPath, getBackendSocketUrl, type HttpAuthAdapter, @@ -363,3 +365,21 @@ describe('media cookie credentials', () => { expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ credentials: 'include' }); }); }); + +describe('structured error details', () => { + it('reads the code and message of a structured detail, and no code from a plain one', () => { + const structured = new ApiError( + JSON.stringify({ detail: { code: 'board_unreadable', message: 'Not authorized to access this board' } }), + 403 + ); + const plain = new ApiError(JSON.stringify({ detail: 'Not allowed' }), 403); + const validation = new ApiError(JSON.stringify({ detail: [{ msg: 'field required' }] }), 422); + + expect(getApiErrorCode(structured)).toBe('board_unreadable'); + expect(getApiErrorMessage(structured, 'fallback')).toBe('Not authorized to access this board'); + expect(getApiErrorCode(plain)).toBeNull(); + expect(getApiErrorCode(validation)).toBeNull(); + expect(getApiErrorCode(new ApiError('', 502))).toBeNull(); + expect(getApiErrorCode(new Error('offline'))).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/platform/transport/http.ts b/invokeai/frontend/webv2/src/platform/transport/http.ts index 86bc3c37798..b5b22094311 100644 --- a/invokeai/frontend/webv2/src/platform/transport/http.ts +++ b/invokeai/frontend/webv2/src/platform/transport/http.ts @@ -296,6 +296,26 @@ export const getApiErrorMessage = (error: unknown, fallback: string): string => return error instanceof Error && error.message ? error.message : fallback; }; +/** + * The `code` of a structured FastAPI detail (`{"code": ..., "message": ...}`), which names why a request was refused + * independently of its wording; null for any other body. + */ +export const getApiErrorCode = (error: unknown): string | null => { + if (!(error instanceof ApiError)) { + return null; + } + + try { + const { detail } = JSON.parse(error.message) as { detail?: unknown }; + const code = + detail && typeof detail === 'object' && !Array.isArray(detail) ? (detail as { code?: unknown }).code : null; + + return typeof code === 'string' && code ? code : null; + } catch { + return null; + } +}; + export const apiFetchJson = async (path: string, init?: RequestInit): Promise => { const requestIdentity = authAdapter.capture().identity; const headers = new Headers(init?.headers); diff --git a/invokeai/frontend/webv2/src/workbench/WorkbenchContext.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/WorkbenchContext.browser.test.tsx index a0fb73eed1a..b744826b313 100644 --- a/invokeai/frontend/webv2/src/workbench/WorkbenchContext.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/WorkbenchContext.browser.test.tsx @@ -1,6 +1,6 @@ import type { GenerationUiAdapter } from '@features/generation/react'; import type * as projectsApi from '@workbench/projects/api'; -import type { ProjectRecordDTO } from '@workbench/projects/api'; +import type { ProjectWithWorkspaceDTO } from '@workbench/projects/api'; import type { WorkbenchCommands } from '@workbench/workbenchStore'; import { ChakraProvider } from '@chakra-ui/react'; @@ -21,7 +21,7 @@ const server = vi.hoisted(() => ({ clientState: new Map(), /** Writes fail as they do for requests an unloading page starts; reads still answer the next page. */ offline: false, - records: new Map(), + records: new Map(), updateGate: null as Promise | null, })); @@ -39,15 +39,18 @@ vi.mock('@workbench/projects/api', async (importOriginal) => { return unreachable(); } const id = request.project_id!; - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { board_id: `board-${id}`, created_at: '2026-10-04T00:00:00.000Z', data: structuredClone(request.data), minimum_canvas_schema_version: request.minimum_canvas_schema_version ?? 3, + minimum_document_schema_version: request.minimum_document_schema_version ?? 3, name: request.name, project_id: id, revision: 1, updated_at: '2026-10-04T00:00:00.000Z', + workspace_updated_at: null, + workspace: null, }; server.calls.push(`create:${request.name}`); server.records.set(id, record); @@ -68,6 +71,25 @@ vi.mock('@workbench/projects/api', async (importOriginal) => { [...server.records.values()].map(({ data: _data, ...summary }) => structuredClone(summary)) ); }, + saveProjectWorkspace: (projectId: string, request: projectsApi.ProjectWorkspaceSaveRequest) => { + if (server.offline) { + return unreachable(); + } + const current = server.records.get(projectId); + if (!current) { + return Promise.reject(notFound()); + } + if ((current.workspace?.revision ?? null) !== request.expected_revision) { + return Promise.reject(new ApiError('conflict', 409)); + } + const workspace = { + data: structuredClone(request.data), + revision: (current.workspace?.revision ?? 0) + 1, + updated_at: '2026-10-04T00:00:00.000Z', + }; + server.records.set(projectId, { ...current, workspace, workspace_updated_at: workspace.updated_at }); + return Promise.resolve(structuredClone(workspace)); + }, setClientStateValue: (key: string, value: string) => { if (server.offline) { return unreachable(); @@ -88,7 +110,7 @@ vi.mock('@workbench/projects/api', async (importOriginal) => { if (current.revision !== request.expected_revision) { throw new ApiError('conflict', 409); } - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { ...current, data: structuredClone(request.data), name: request.name, @@ -315,7 +337,9 @@ describe('WorkbenchProvider hidden page', () => { await leaveEditor(); await mountEditor({ withStrength: true }); await vi.waitFor(() => expect(strengthSlider()).not.toBeNull(), { timeout: 5_000 }); - const savedBeforeEdit = JSON.stringify(server.records.get(projectId)?.data); + const savedBeforeEdit = JSON.stringify(server.records.get(projectId)?.workspace?.data); + // The workspace was saved with the project, so the checks below compare against a real copy. + expect(savedBeforeEdit).toEqual(expect.any(String)); const slider = strengthSlider()!; await act(() => { @@ -330,9 +354,12 @@ describe('WorkbenchProvider hidden page', () => { expect(document.activeElement).toBe(slider); expect(strengthValue()).toBeCloseTo(typedStrength); // Well before the 250 ms draft debounce and 500 ms autosave could have saved it on their own. - await vi.waitFor(() => expect(JSON.stringify(server.records.get(projectId)?.data)).not.toBe(savedBeforeEdit), { - timeout: 600, - }); + await vi.waitFor( + () => expect(JSON.stringify(server.records.get(projectId)?.workspace?.data)).not.toBe(savedBeforeEdit), + { + timeout: 600, + } + ); // The page goes away: its lifetime ends without the editor's exit checkpoint, and a new one loads it again. const { storageSuffix } = accountLifecycle.capture(); @@ -351,7 +378,9 @@ describe('WorkbenchProvider hidden page', () => { await leaveEditor(); await mountEditor({ withStrength: true }); await vi.waitFor(() => expect(strengthSlider()).not.toBeNull(), { timeout: 5_000 }); - const savedBeforeEdit = JSON.stringify(server.records.get(projectId)?.data); + const savedBeforeEdit = JSON.stringify(server.records.get(projectId)?.workspace?.data); + // The workspace was saved with the project, so the checks below compare against a real copy. + expect(savedBeforeEdit).toEqual(expect.any(String)); const slider = strengthSlider()!; await act(() => { @@ -369,12 +398,15 @@ describe('WorkbenchProvider hidden page', () => { expect(activeProject()!.id).toBe(projectId); expect(strengthValue()).toBeCloseTo(typedStrength); - expect(JSON.stringify(server.records.get(projectId)?.data)).toBe(savedBeforeEdit); + expect(JSON.stringify(server.records.get(projectId)?.workspace?.data)).toBe(savedBeforeEdit); // Recovered as a pending edit, which the next page saves once it can. revivePage!(); - await vi.waitFor(() => expect(JSON.stringify(server.records.get(projectId)?.data)).not.toBe(savedBeforeEdit), { - timeout: 5_000, - }); + await vi.waitFor( + () => expect(JSON.stringify(server.records.get(projectId)?.workspace?.data)).not.toBe(savedBeforeEdit), + { + timeout: 5_000, + } + ); }); it('does not let the journal of a back/forward-cached page override work saved after it resumed', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts index a20b48b5370..7d850b10de7 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -91,6 +91,7 @@ import type { LayerTransform } from '@workbench/canvas-engine/transform/transfor import type { Rect, RenderFlags, ToolId, Vec2 } from '@workbench/canvas-engine/types'; import type { CanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import { createItemId } from '@platform/browser/randomUuid'; import { areJsonValuesStructurallyEqual } from '@platform/core/json'; import { PixelEditController } from '@workbench/canvas-engine/controllers/controlPixelController'; import { EditingController } from '@workbench/canvas-engine/controllers/editingController'; @@ -320,7 +321,7 @@ export interface CanvasEngineCoreComposition { } /** Mints a fresh layer id for engine-created paint layers. */ -const createLayerId = (): string => `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const createLayerId = (): string => createItemId('layer'); const createEventId = (): string => `event-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const clearSurface = (surface: RasterSurface): void => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.test.ts index 96c26a53dc9..ab62a20157e 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.test.ts @@ -1,3 +1,4 @@ +import type { GalleryBoard, GalleryBoardAction } from '@features/gallery/contracts'; import type { GenerateModelConfig, GenerateWidgetValues, MainModelConfig } from '@features/generation/contracts'; import type { CanvasDocumentContractV3 } from '@workbench/canvas-engine/contracts'; import type { CanvasEngine } from '@workbench/canvas-engine/engine'; @@ -5,15 +6,17 @@ import type { RasterCompositeExportResult } from '@workbench/canvas-engine/expor import type { Rect } from '@workbench/canvas-engine/types'; import type { Project } from '@workbench/projectContracts'; +import { galleryKeys } from '@features/gallery/queries'; import { getDefaultGenerateSettings } from '@features/generation/settings'; -import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { queryClient } from '@platform/query/client'; +import { accountLifecycle, captureAccountScope } from '@platform/state/accountLifecycle'; import { getProjectWidgetInstance } from '@workbench/widgetState'; import { createInitialWorkbenchState } from '@workbench/workbenchState'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { uploadCanvasImage } from './backend/canvasImages'; -import { saveCanvasToGallery } from './saveCanvasToGallery'; +import { getCanvasSaveBoardId, saveCanvasToGallery } from './saveCanvasToGallery'; const model: MainModelConfig = { base: 'sd-1', @@ -107,9 +110,37 @@ const createHarness = ( }; }; +/** A fresh listing of every readable board, as `ensureGalleryBoardDestinations` leaves it: the boards named are destinations. */ +const listGalleryBoardsInCache = (...boardIds: string[]) => + queryClient.setQueryData( + galleryKeys.boards(captureAccountScope(), { + includeArchived: true, + includeDateBoards: false, + orderBy: 'created_at', + orderDir: 'DESC', + }), + boardIds.map((id) => ({ + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 0, + isInbox: false, + kind: 'board' as const, + name: id, + projectId: null, + videoCount: 0, + })) + ); + describe('saveCanvasToGallery', () => { + afterEach(() => { + queryClient.clear(); + }); + beforeEach(() => { accountLifecycle.activate('save-canvas-test'); + listGalleryBoardsInCache('board-1', 'board-physical', 'project-board'); }); afterEach(() => { @@ -311,3 +342,53 @@ describe('saveCanvasToGallery', () => { expect(harness.uploadImage).not.toHaveBeenCalled(); }); }); + +describe('getCanvasSaveBoardId', () => { + afterEach(() => { + queryClient.clear(); + }); + + it('saves to the project inbox while the selected board is one the caller may only view', () => { + accountLifecycle.activate('save-canvas-destination'); + const board = (id: string, actions: GalleryBoardAction[]): GalleryBoard => ({ + access: { + actions, + adminOverride: false, + generalAccess: 'viewer', + role: actions.includes('rename') ? 'owner' : 'viewer', + }, + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 0, + isInbox: id === 'inbox', + kind: 'board', + name: id, + projectId: null, + videoCount: 0, + }); + queryClient.setQueryData( + galleryKeys.boards(captureAccountScope(), { + includeArchived: false, + includeDateBoards: false, + orderBy: 'created_at', + orderDir: 'DESC', + }), + [ + board('inbox', ['read', 'add_media', 'rename']), + board('shared', ['read']), + board('moodboard', ['read', 'add_media']), + ] + ); + const withInbox = (selected: string) => { + const project = createProject(selected); + const gallery = getProjectWidgetInstance(project, 'gallery')!; + gallery.state.values = { ...gallery.state.values, projectBoardId: 'inbox' }; + return project; + }; + + expect(getCanvasSaveBoardId(withInbox('shared'))).toBe('inbox'); + expect(getCanvasSaveBoardId(withInbox('moodboard'))).toBe('moodboard'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts index 7cf40ecf752..1aef2ef1f64 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts @@ -2,12 +2,14 @@ import type { Rect } from '@workbench/canvas-engine/types'; import type { Project } from '@workbench/projectContracts'; import { getGalleryAutoAddBoardId } from '@features/gallery/contracts'; +import { canAddToCachedGalleryBoard, ensureGalleryBoardDestinations } from '@features/gallery/queries'; import { toModelIdentifier } from '@features/generation/graph'; import { getEffectivePrompts, isSupportedGenerateModel, normalizeGenerateWidgetValues, } from '@features/generation/settings'; +import { queryClient } from '@platform/query/client'; import { assertAccountScopeCurrent, captureAccountScope, @@ -46,13 +48,32 @@ const buildCanvasSaveMetadata = (project: Project, rect: Rect): Record { - const boardId = getGalleryAutoAddBoardId(getProjectWidgetValues(project, 'gallery')); +const toCanvasSaveBoardId = (galleryValues: Record): string | undefined => { + const boardId = getGalleryAutoAddBoardId(galleryValues, canAddToCachedGalleryBoard); return boardId !== null && boardId !== 'none' ? boardId : undefined; }; +/** + * Where Canvas puts new gallery images: the project's auto-add board, or no board (Uncategorized), as the cached + * board listing says. Callers resolve through `resolveCanvasSaveBoardId`, which has the listing read first. + */ +export const getCanvasSaveBoardId = (project: Project): string | undefined => + toCanvasSaveBoardId(getProjectWidgetValues(project, 'gallery')); + +/** + * `getCanvasSaveBoardId` from a listing read first, so a board is never trusted unseen nor redirected for want of + * one. The project's gallery values are read before anything is awaited: a later change to the project cannot + * redirect a save already under way. + */ +export const resolveCanvasSaveBoardId = async (project: Project): Promise => { + const galleryValues = getProjectWidgetValues(project, 'gallery'); + + await ensureGalleryBoardDestinations(queryClient); + + return toCanvasSaveBoardId(galleryValues); +}; + export const saveCanvasToGallery = async (options: { engine: CanvasCompositeExportEngine; region: CanvasGallerySaveRegion; @@ -61,9 +82,10 @@ export const saveCanvasToGallery = async (options: { }): Promise => { const owner = captureAccountScope(); const { engine, project, region, uploadImage = uploadCanvasImage } = options; - const boardId = getCanvasSaveBoardId(project); try { + const boardId = await resolveCanvasSaveBoardId(project); + assertAccountScopeCurrent(owner); const exported = await exportCanvasComposite(engine, region); assertAccountScopeCurrent(owner); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveStagedResultToGallery.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveStagedResultToGallery.test.ts index 91f22ffc1d6..721602f34af 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveStagedResultToGallery.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveStagedResultToGallery.test.ts @@ -1,7 +1,9 @@ import type { GalleryImage } from '@features/gallery'; import type { Project } from '@workbench/projectContracts'; -import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { galleryKeys } from '@features/gallery/queries'; +import { queryClient } from '@platform/query/client'; +import { accountLifecycle, captureAccountScope } from '@platform/state/accountLifecycle'; import { getProjectWidgetInstance } from '@workbench/widgetState'; import { createInitialWorkbenchState } from '@workbench/workbenchState.testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -40,8 +42,33 @@ const deferred = () => { return { promise, resolve }; }; +/** A fresh listing of every readable board, as `ensureGalleryBoardDestinations` leaves it: the boards named are destinations. */ +const listGalleryBoardsInCache = (...boardIds: string[]) => + queryClient.setQueryData( + galleryKeys.boards(captureAccountScope(), { + includeArchived: true, + includeDateBoards: false, + orderBy: 'created_at', + orderDir: 'DESC', + }), + boardIds.map((id) => ({ + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 0, + isInbox: false, + kind: 'board' as const, + name: id, + projectId: null, + videoCount: 0, + })) + ); + beforeEach(() => { accountLifecycle.activate('staged-save-user'); + queryClient.clear(); + listGalleryBoardsInCache('board-a', 'board-b', 'board-z'); }); describe('saveStagedResultToGallery', () => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveStagedResultToGallery.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveStagedResultToGallery.ts index c4467044d1e..186ca223eac 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveStagedResultToGallery.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveStagedResultToGallery.ts @@ -8,7 +8,7 @@ import { isAccountScopeCurrent, } from '@platform/state/accountLifecycle'; -import { getCanvasSaveBoardId } from './saveCanvasToGallery'; +import { resolveCanvasSaveBoardId } from './saveCanvasToGallery'; /** `boardId` is where the image is meant to be; `'none'` is Uncategorized. */ export type SaveStagedResultOutcome = @@ -50,7 +50,7 @@ const assignBoard = async ( /** * Promote a staged canvas result into the gallery with the same destination as a canvas save: a board the image is * already on stays; otherwise the project's auto-add board, else Uncategorized. The destination is read from the - * project before anything is awaited, so switching projects mid-save cannot redirect it. A failed promotion throws; + * `project` snapshot handed in, so switching projects mid-save cannot redirect it. A failed promotion throws; * a failed board assignment is reported as such, since the image is in the gallery either way. */ export const saveStagedResultToGallery = async ( @@ -58,11 +58,13 @@ export const saveStagedResultToGallery = async ( transports: StagedResultTransports = {} ): Promise => { const owner = captureAccountScope(); - const canvasBoardId = getCanvasSaveBoardId(project) ?? 'none'; const { promote = galleryDurability.save } = transports; + let canvasBoardId: string; let record: GalleryImage; try { + canvasBoardId = (await resolveCanvasSaveBoardId(project)) ?? 'none'; + assertAccountScopeCurrent(owner); record = await promote(imageName); assertAccountScopeCurrent(owner); } catch (error) { diff --git a/invokeai/frontend/webv2/src/workbench/canvasMigration.ts b/invokeai/frontend/webv2/src/workbench/canvasMigration.ts index d6ee177e59f..9d91441f0c5 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasMigration.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasMigration.ts @@ -12,6 +12,7 @@ import type { LayerStackKind, } from '@workbench/canvas-engine/api'; +import { createItemId } from '@platform/browser/randomUuid'; import { CANVAS_MAX_NODE_COUNT, CANVAS_MAX_NODE_DEPTH, LAYER_STACK_ORDER } from '@workbench/canvas-engine/api'; import { repairSelectedLayerId } from '@workbench/canvas-engine/document/selectionRepair'; import { z } from 'zod'; @@ -24,9 +25,6 @@ import { normalizeControlAdapter } from './controlAdapters'; export const DEFAULT_CANVAS_DOCUMENT_WIDTH = 1024; export const DEFAULT_CANVAS_DOCUMENT_HEIGHT = 1024; -const createMigrationId = (prefix: string): string => - `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -320,7 +318,7 @@ export const createEmptyCanvasDocument = ( /** Mirror the default inpaint mask without importing the Layers panel into this pure module. */ const createInitialInpaintMaskLayer = (): CanvasInpaintMaskLayerContract => ({ blendMode: 'normal', - id: createMigrationId('layer'), + id: createItemId('layer'), isEnabled: true, isLocked: false, mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, diff --git a/invokeai/frontend/webv2/src/workbench/graphPreviewInvocation.test.ts b/invokeai/frontend/webv2/src/workbench/graphPreviewInvocation.test.ts index e4d45d4b34a..ef82a4db3b5 100644 --- a/invokeai/frontend/webv2/src/workbench/graphPreviewInvocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/graphPreviewInvocation.test.ts @@ -5,12 +5,14 @@ import type { ComponentModelConfig, } from '@features/generation/contracts'; +import { galleryKeys } from '@features/gallery/queries'; import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { getDefaultGenerateSettings } from '@features/generation/settings'; +import { queryClient } from '@platform/query/client'; import { captureAccountScope } from '@platform/state/accountLifecycle'; import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState.testing'; import { createWorkbenchStore } from '@workbench/workbenchStore'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { resolveAndSubmitGraphPreviewInvocation } from './graphPreviewInvocation'; import { beginInvocationPreparation, endInvocationPreparation } from './invocationPreparation'; @@ -65,7 +67,35 @@ const withCanvasWidgetMounted = ( }, }); +/** A fresh listing of every readable board, as `ensureGalleryBoardDestinations` leaves it: the boards named are destinations. */ +const listGalleryBoardsInCache = (...boardIds: string[]) => + queryClient.setQueryData( + galleryKeys.boards(captureAccountScope(), { + includeArchived: true, + includeDateBoards: false, + orderBy: 'created_at', + orderDir: 'DESC', + }), + boardIds.map((id) => ({ + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 0, + isInbox: false, + kind: 'board' as const, + name: id, + projectId: null, + videoCount: 0, + })) + ); + describe('resolveAndSubmitGraphPreviewInvocation', () => { + beforeEach(() => { + queryClient.clear(); + listGalleryBoardsInCache(); + }); + it('routes a canvas source through prepareCanvasInvocation and does not dispatch a resolved snapshot', () => { const project = withCanvasWidgetMounted(getActiveProject(createGenerateValues())); const commands = createWorkbenchStore().commands; 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 906e01ae47d..a4c259d7c46 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 @@ -1,4 +1,5 @@ -import type { GalleryImage, GalleryItem, GalleryItemKey } from '@features/gallery'; +/* oxlint-disable react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-new-array-as-prop */ +import type { GalleryBoard, GalleryImage, GalleryItem, GalleryItemKey } from '@features/gallery'; import type { GalleryItemContextMenuTarget } from '@features/gallery/react'; /* oxlint-disable react-perf/jsx-no-new-function-as-prop */ @@ -702,3 +703,72 @@ describe('ImageContextMenu mixed-media action visibility', () => { expect(actions.setItemsStarred).toHaveBeenCalledWith(refs, true); }); }); + +describe('ImageContextMenu change-board destinations', () => { + const board = (id: string, access: NonNullable): GalleryBoard => ({ + access, + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 1, + isInbox: false, + kind: 'board', + name: id, + ownerName: access.role === 'owner' ? 'Me' : 'Dee', + projectId: null, + videoCount: 0, + }); + const renderWithBoards = async (boards: GalleryBoard[]) => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + + await interact(() => { + root?.render( + + + + ); + }); + const trigger = Array.from(document.querySelectorAll('[role="menuitem"]')).find((candidate) => + candidate.textContent?.includes('widgets.gallery.changeBoard') + ); + expect(trigger).not.toBeUndefined(); + await hoverItem(trigger!); + await settleUntil( + () => document.querySelector('[data-value="move-to-theirs"]') !== null, + 'the change-board submenu to list its boards' + ); + + return Array.from(document.querySelectorAll('[data-scope="menu"][data-part="item-group-label"]')).map( + (label) => label.textContent?.trim() + ); + }; + + it("groups the boards as the host lists them: another user's board apart, the caller's own under Library", async () => { + expect( + await renderWithBoards([ + board('mine', { + actions: ['read', 'add_media'], + adminOverride: false, + generalAccess: 'restricted', + role: 'owner', + }), + // An admin's view of someone else's board; a host without board sharing folds it before handing it over. + board('theirs', { + actions: ['read', 'add_media'], + adminOverride: true, + generalAccess: 'restricted', + role: null, + }), + ]) + ).toEqual(['widgets.gallery.boardGroups.library', 'widgets.gallery.boardGroups.otherUsers']); + expect(document.querySelector('[data-value="move-to-mine"]')).not.toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index 0be50316ed6..dc815a7f4f6 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -181,6 +181,7 @@ export const ImageContextMenu = ({ onClose, }: { actions: ImageActions; + /** The host's board listing as it shows it: folded through `useHostGalleryBoards` where boards cannot be shared. */ boards: GalleryBoard[]; previewVideoActions?: PreviewVideoContextActions; target: ImageContextMenuTarget | null; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index a5a3b86bfec..e59220f5435 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -44,6 +44,7 @@ import { isAccountScopeCurrent, } from '@platform/state/accountLifecycle'; import { useExternalStoreSelector } from '@platform/state/selectors'; +import { getApiErrorMessage } from '@platform/transport/http'; import { useQueryClient } from '@tanstack/react-query'; import { createCanvasFromImages, @@ -170,7 +171,9 @@ const createVideoPlacementRoomSelector = (models: readonly ModelConfig[], projec }; }; -const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +// The server's own words for a refusal (an item on someone else's board, say), not its JSON body. +const toErrorMessage = (error: unknown): string => + getApiErrorMessage(error, error instanceof Error ? error.message : String(error)); const toPngBlob = async (blob: Blob): Promise => { if (blob.type === 'image/png') { diff --git a/invokeai/frontend/webv2/src/workbench/invocation.test.ts b/invokeai/frontend/webv2/src/workbench/invocation.test.ts index 8f7baa8dbd8..786ccb8fd37 100644 --- a/invokeai/frontend/webv2/src/workbench/invocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocation.test.ts @@ -10,6 +10,7 @@ import type { CanvasControlLayerContract, CanvasLayerContract } from '@workbench import type { InvocationRoute } from '@workbench/invocationContracts'; import type { WidgetId } from '@workbench/widgetContracts'; +import { galleryKeys } from '@features/gallery/queries'; import { getDefaultGenerateSettings } from '@features/generation/settings'; import { createDefaultUpscaleWidgetValues } from '@features/upscale'; import { queryClient } from '@platform/query/client'; @@ -383,12 +384,36 @@ describe('resolveInvocationRoute — canvas source', () => { }); }); +/** A fresh listing of every readable board, as `ensureGalleryBoardDestinations` leaves it: the boards named are destinations. */ +const listGalleryBoardsInCache = (...boardIds: string[]) => + queryClient.setQueryData( + galleryKeys.boards(captureAccountScope(), { + includeArchived: true, + includeDateBoards: false, + orderBy: 'created_at', + orderDir: 'DESC', + }), + boardIds.map((id) => ({ + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 0, + isInbox: false, + kind: 'board' as const, + name: id, + projectId: null, + videoCount: 0, + })) + ); + describe('submitResolvedInvocation', () => { const routeFor = (project: Parameters[0], route: InvocationRoute) => resolveInvocationRoute(project, 'global', route); beforeEach(() => { queryClient.clear(); + listGalleryBoardsInCache(); parseDynamicPromptsMock.mockReset(); parseDynamicPromptsMock.mockResolvedValue({ prompts: ['a red cat', 'a green cat'] }); }); diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/invocationSubmit.browser.test.tsx new file mode 100644 index 00000000000..b8c00d43114 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.browser.test.tsx @@ -0,0 +1,220 @@ +import type { GalleryBoard } from '@features/gallery/contracts'; +import type { MainModelConfig } from '@features/generation/contracts'; + +import { + canAddToCachedGalleryBoard, + galleryBoardsOptions, + invalidateGallery, + invalidateGalleryBoardDestinations, +} from '@features/gallery/queries'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; +import { getDefaultGenerateSettings } from '@features/generation/settings'; +import { queryClient } from '@platform/query/client'; +import { accountLifecycle, captureAccountScope } from '@platform/state/accountLifecycle'; +import { QueryObserver } from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveInvocationRoute } from './invocation'; +import { submitResolvedInvocation } from './invocationSubmit'; +import { createInitialWorkbenchState, workbenchReducer } from './workbenchState.testing'; +import { createWorkbenchStore } from './workbenchStore'; + +/** + * What a submission costs in board-listing reads, pinned against the transport: a cached listing of every readable + * board decides at once, whatever its age; only with none cached is one read awaited before the dispatch; and + * invalidating the gallery reads back only the listings something observes. + */ + +const BOARDS_LISTING_PATH = '/api/v1/boards/'; + +/** Answers the board listing and its count requests, holding the listing back while `holdListing` is set. */ +const createBoardsServer = () => { + const requests: string[] = []; + let releaseListing: (() => void) | null = null; + let holdListing = false; + const respond = (body: unknown) => + new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status: 200 }); + const fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = new URL(input instanceof Request ? input.url : String(input), 'http://localhost'); + + requests.push(`${init?.method ?? 'GET'} ${url.pathname}${url.search}`); + + if (url.pathname === BOARDS_LISTING_PATH) { + if (holdListing) { + await new Promise((resolve) => { + releaseListing = resolve; + }); + } + + return respond([]); + } + + return respond({ total: 0 }); + }; + + return { + fetch, + hold: () => { + holdListing = true; + }, + listingReads: () => requests.filter((request) => request.startsWith(`GET ${BOARDS_LISTING_PATH}`)), + release: () => { + holdListing = false; + releaseListing?.(); + }, + requests, + }; +}; + +const board = (id: string): GalleryBoard => ({ + archived: false, + assetCount: 0, + assetVideoCount: 0, + id, + imageCount: 0, + isInbox: false, + kind: 'board', + name: id, + projectId: null, + videoCount: 0, +}); + +const model: MainModelConfig = { base: 'sd-1', key: 'main', name: 'Main', type: 'main' }; + +const buildGenerateProject = () => { + const state = workbenchReducer(createInitialWorkbenchState(), { + type: 'setGenerateSettings', + values: { ...getDefaultGenerateSettings(model), model, modelKey: model.key, positivePrompt: 'a cat' }, + }); + + return state.projects.find((candidate) => candidate.id === state.activeProjectId)!; +}; + +/** Submits a plain Generate run bound for the gallery; `dispatched` records the moment the reducer took it. */ +const submitGenerate = () => { + const project = buildGenerateProject(); + const route = resolveInvocationRoute(project, 'global', { + destination: 'gallery', + destinationLocked: false, + sourceId: 'generate', + sourceLocked: false, + }); + const commands = createWorkbenchStore().commands; + const dispatched = vi.spyOn(commands.generation, 'submitResolved').mockImplementation(() => undefined); + + expect(route.validationReasons).toEqual([]); + + const submission = submitResolvedInvocation({ + commands, + formatControlLayerError: ({ code, layerName }) => `${layerName}: ${code}`, + models: undefined, + owner: captureAccountScope(), + prepareCanvasInvocation: vi.fn(), + project, + route, + }); + + return { dispatched, submission }; +}; + +const settle = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +let server: ReturnType; + +seedArchitectureCapabilities(); + +beforeEach(() => { + accountLifecycle.activate('invocation-submit-listing-cost'); + server = createBoardsServer(); + vi.stubGlobal('fetch', server.fetch); +}); + +afterEach(async () => { + server.release(); + await vi.waitFor(() => expect(queryClient.isFetching()).toBe(0)); + queryClient.clear(); + vi.unstubAllGlobals(); + accountLifecycle.invalidate(); +}); + +describe('board-listing reads before a submission', () => { + it.each([ + ['fresh', 0], + ['a minute old', 61_000], + ])('dispatches at once from a cached listing of every readable board that is %s', async (_age, ageMs) => { + queryClient.setQueryData(galleryBoardsOptions({ includeArchived: true }).queryKey, [board('own')], { + updatedAt: Date.now() - ageMs, + }); + server.hold(); + + const { dispatched, submission } = submitGenerate(); + + // Nothing was awaited: the run was dispatched with the listing still unanswered. + await submission; + expect(dispatched).toHaveBeenCalledTimes(1); + expect(server.listingReads()).toHaveLength(ageMs > 0 ? 1 : 0); + + // The stale listing's one background read lands for the next run; the fresh listing is left alone. + server.release(); + await settle(); + expect(server.listingReads()).toHaveLength(ageMs > 0 ? 1 : 0); + }); + + it('awaits exactly one listing read before the dispatch when none is cached', async () => { + server.hold(); + + const { dispatched, submission } = submitGenerate(); + await settle(); + + expect(dispatched).not.toHaveBeenCalled(); + expect(server.listingReads()).toHaveLength(1); + + server.release(); + await submission; + expect(dispatched).toHaveBeenCalledTimes(1); + expect(server.listingReads()).toHaveLength(1); + }); +}); + +describe('after a board refused a run', () => { + it('reads the listing again at once, and a submit that follows the read decides from it', async () => { + queryClient.setQueryData(galleryBoardsOptions({ includeArchived: true }).queryKey, [board('refusing')]); + + // The server no longer lists the board; nothing observes the listing. + await invalidateGalleryBoardDestinations(queryClient); + expect(server.listingReads()).toHaveLength(1); + expect(canAddToCachedGalleryBoard('refusing', queryClient)).toBe(false); + + const { dispatched, submission } = submitGenerate(); + await submission; + + expect(dispatched).toHaveBeenCalledTimes(1); + expect(server.listingReads()).toHaveLength(1); + }); +}); + +describe('invalidating the gallery', () => { + it('reads back only the board listings something observes', async () => { + queryClient.setQueryData(galleryBoardsOptions({ includeArchived: true }).queryKey, [board('own')]); + queryClient.setQueryData(galleryBoardsOptions().queryKey, [board('own')]); + + // No Generate, Video or paste surface observes a listing: nothing is read back. + await invalidateGallery(queryClient); + await settle(); + expect(server.requests).toEqual([]); + + // The panel observes its own listing of live boards, invalidated above so it is read back on mount; after that, + // an invalidation reads back that one listing, not the one with archived boards. + const stopObserving = new QueryObserver(queryClient, galleryBoardsOptions()).subscribe(() => undefined); + await vi.waitFor(() => expect(queryClient.isFetching()).toBe(0)); + server.requests.length = 0; + await invalidateGallery(queryClient); + await vi.waitFor(() => expect(queryClient.isFetching()).toBe(0)); + stopObserving(); + + expect(server.listingReads()).toEqual([expect.stringContaining('include_archived=false')]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts b/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts index 8fd7715ecc3..d904fdfb18b 100644 --- a/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts @@ -16,6 +16,14 @@ const templatesMock = vi.hoisted(() => ({ }, })); const generatorsMock = vi.hoisted(() => ({ resolveWorkflowGenerators: vi.fn() })); +// A fresh listing is cached unless a test says otherwise: nothing to await, so dispatches stay synchronous. +const galleryQueriesMock = vi.hoisted(() => ({ + ensureGalleryBoardDestinations: vi.fn((): Promise | null => null), +})); +vi.mock('@features/gallery/queries', async (importOriginal) => ({ + ...(await importOriginal>()), + ensureGalleryBoardDestinations: galleryQueriesMock.ensureGalleryBoardDestinations, +})); vi.mock('@features/workflow/react', async (importOriginal) => ({ ...(await importOriginal>()), @@ -189,6 +197,63 @@ describe('submitResolvedInvocation with a workflow generator', () => { return { notify, submitResolved }; }; + it('reads the board listing alongside generator resolution and dispatches only once both have answered', async () => { + galleryQueriesMock.ensureGalleryBoardDestinations.mockClear(); + let settleBoards!: () => void; + galleryQueriesMock.ensureGalleryBoardDestinations.mockReturnValue( + new Promise((resolve) => { + settleBoards = resolve; + }) + ); + let settleGenerators!: () => void; + const generators = new Promise((resolve) => { + settleGenerators = resolve; + }); + generatorsMock.resolveWorkflowGenerators.mockImplementation( + async (_client: unknown, pending: Array<{ key: string; nodeId: string }>) => { + await generators; + return { + 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'); + const settle = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + try { + const submission = submitResolvedInvocation({ + commands, + formatControlLayerError, + models: undefined, + owner: captureAccountScope(), + prepareCanvasInvocation: vi.fn(), + project, + route: resolveInvocationRoute(project, 'global', workflowRoute), + }); + + // The listing read did not hold generator resolution back: both started at once. + expect(galleryQueriesMock.ensureGalleryBoardDestinations).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(generatorsMock.resolveWorkflowGenerators).toHaveBeenCalledTimes(1)); + + // Generators answered; the dispatch still waits for the listing the destination is judged from. + settleGenerators(); + await settle(); + expect(submitResolved).not.toHaveBeenCalled(); + + settleBoards(); + await submission; + expect(submitResolved).toHaveBeenCalledTimes(1); + } finally { + galleryQueriesMock.ensureGalleryBoardDestinations.mockReturnValue(null); + } + }); + it('resolves the pending generator and dispatches its output with the snapshot', async () => { generatorsMock.resolveWorkflowGenerators.mockImplementation( (_client: unknown, pending: Array<{ key: string; nodeId: string }>) => diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts b/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts index 10bfe9166c5..194509ceb92 100644 --- a/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts @@ -12,6 +12,7 @@ import type { AccountScope } from '@platform/state/accountLifecycle'; import type { ExpandedPositivePrompts, ResolvedInvocationRoute } from '@workbench/invocationContracts'; import type { Project } from '@workbench/projectContracts'; +import { ensureGalleryBoardDestinations } from '@features/gallery/queries'; import { resolveDynamicPrompts } from '@features/generation/prompts'; import { getEffectivePrompts, @@ -195,6 +196,17 @@ export const submitResolvedInvocation = async ({ return; } + // The reducer resolves where results land (the auto-add board, a workflow node's board) from the cached board + // listing, so a board is judged from a listing and never trusted unseen. Any cached listing decides at once, a + // stale one refreshing in the background for the next run; only with none cached does a read start now, alongside + // generator resolution and prompt expansion, to be awaited as the run is dispatched. So this path is synchronous + // but for the first run after the listings were cleared, which the Invoke control shows as preparing. A run bound + // for the canvas names no board, unless it is a workflow, whose nodes carry their boards whatever the destination. + const pendingBoards = + route.destination === 'gallery' || route.sourceId === 'workflow' + ? ensureGalleryBoardDestinations(queryClient) + : null; + // Only a workflow with unresolved generators needs this round trip; every other route dispatches synchronously. const pendingGenerators = getPendingWorkflowGenerators(workflowDocument, route); let workflowGenerators: WorkflowGeneratorResolutions | undefined; @@ -254,7 +266,8 @@ export const submitResolvedInvocation = async ({ ? { positivePrompts: expansion.prompts, positivePromptsSampleSeed: sampleSeed } : undefined, workflowGenerators, - workflowDocument?.id + workflowDocument?.id, + pendingBoards ); return; } @@ -263,7 +276,8 @@ export const submitResolvedInvocation = async ({ { commands, formatControlLayerError, models, owner, prepareCanvasInvocation, prepend, project, route, workflowId }, undefined, workflowGenerators, - workflowDocument?.id + workflowDocument?.id, + pendingBoards ); }; @@ -280,8 +294,18 @@ const dispatchResolvedInvocation = async ( }: SubmitResolvedInvocationDeps, expansion: ExpandedPositivePrompts | undefined, workflowGenerators: WorkflowGeneratorResolutions | undefined, - workflowId: string | undefined + workflowId: string | undefined, + pendingBoards: Promise | null ): Promise => { + // The board listing the destination is judged from, read while the rest was resolved when none was cached; + // nothing to wait for keeps the dispatch synchronous. + if (pendingBoards) { + await pendingBoards; + if (!isAccountScopeCurrent(owner)) { + return; + } + } + if (route.sourceId === 'canvas') { // Await Canvas preparation to retain the submission guard until completion; pass the resolved destination so // Canvas can output to Gallery. diff --git a/invokeai/frontend/webv2/src/workbench/projects/README.md b/invokeai/frontend/webv2/src/workbench/projects/README.md index a96bdc93e79..944ea26feab 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/README.md +++ b/invokeai/frontend/webv2/src/workbench/projects/README.md @@ -6,13 +6,21 @@ Backend project record is authoritative. Writes use `expected_revision`; diverge `ProjectDocumentV3` allowlists editable document fields. Optional fields added within schema 3 (`lastFloatingGeometry`, `presetWorkingLayouts`) are additive: absent means none, so older schema-3 documents load unchanged, and a build predating a field ignores it and drops it from the next document it saves to the server instead of refusing the project (a schema bump would refuse it). Queue runs, events, undo and per-workflow edit histories are not project documents. Documents capped at 32 MiB of UTF-8 JSON on both sides of the API. +## Content and workspace + +The server holds a project as two documents (document schema 4): its **content**, what the project is and every member shares (name; the canvas document, its snapshots and revision counter; workflow entries), and each member's **workspace**, how they work on it (layout, widgets and generation settings, prompt history, project settings, the canvas selection and staging area, the open workflow and each workflow's last run). The browser keeps one whole document per project, in memory, drafts and the unload journal; `workbench/projects/projectHalves.ts` splits it at the server boundary and joins the two halves back when a project loads. A member with no workspace yet joins onto the workspace a new project starts with. + +Each half has its own revision. A project the server holds whole (saved before the split) is split by its next save, whether or not anything changed, unless it is conflicted or refused: one `PUT /projects/{id}` carries the content, the document floor of 4 (so a build that predates the split is refused the project rather than saving it whole) and the member's workspace, which the server stores with the content in one transaction and answers beside it. The split of a whole project never lands without the member's workspace (the server refuses it without one), so a page gone right after that request (sign-out deletes its drafts) loses nothing; a workspace revision mismatch refuses the content with it. Every create is one request the same way: a project's first save, a save as a copy, an import and a duplicate send the content with the creator's workspace (`POST /projects` with `workspace`), and the answer carries the workspace it stored, so the server never holds a split project's content without its creator's workspace. A document created whole (below) carries the workspace inside and sends none; the server refuses a workspace beside a whole document. A create settled by a read of an earlier attempt that committed unanswered is judged by its content, so the workspace it answers with is taken as pushed only when it is the one this save sent; otherwise it stays owed and is saved next against the revision the answer named. Which board a created workspace names follows two conventions, both right because load applies `applyAuthoritativeProjectBoard(..., { selectBoard: false })`: import and duplicate stage the inbox first, so the workspace in the create already names it; a project's first save sends the workspace without a board, and `pendingBoardAssignments` applies the one the answer minted locally, which the next save writes. Only once split are the halves saved separately, and a save then sends only the half that changed: the content first, then the workspace, one after the other, since the server takes one project write per account at a time; every project write declares the canvas and document schemas the build reads, and the server refuses one above them. Each half is recorded as acknowledged as soon as it is, so a retry sends only what is still owed, and a content acknowledgement rebases the lineage's draft and journal entry before the workspace is sent, so recovery after a failed workspace save builds on the saved content. The draft is acknowledged when both are. A revision conflict in either half follows the same "changed elsewhere" flow, and the banner says when it is the caller's settings; using the server version then takes the server's workspace and keeps this tab's latest content, unless the server's content has moved since this tab's base, which is then reported as a content conflict instead. On a revision conflict a half the server holds as one of the page's own recent sends (its answer lost) is retried, not reported, which covers the split whose answer was lost. A project whose workflow repeats an item id (possible only before the split) keeps saving whole instead, and its first save, a save as a copy, or a duplicate of it creates it whole, since split content repeating one would be refused by every later load. A split project whose workflow comes to repeat one (only an editing bug could) is not saved, and the error asks for the change to be undone; export would flush the same save, so it cannot be the way out. + +Drafts and journal entries carry a base revision for each half (`baseRevision` for the content, `baseWorkspaceRevision`, absent in records written before the split, for the workspace). A recovered draft is judged half by half: a half equal to the server's needs no base, which covers a page that saved the content and was gone before the workspace, and a changed half conflicts only when the server moved it past the draft's base. + ## Workflows Project owns an ordered workflow collection (`workflows.entries`) and remembers its active workflow. Each entry carries the editable document, optional source metadata (library template it was opened from or last explicitly saved to, with the content revision then observed, or `null` when unknown) and the newest successful run's output. Source metadata sits beside the document, outside graph edit history, so undoing an edit never undoes a save. `workbench/projectWorkflows.ts` owns membership, selection and session histories (one per workflow, 40 entries per project across all, structurally shared); Workflow owns editing, serialization, library transport, presentation. Projects autosave their workflows. Library changes only through explicit publication (`features/workflow/data/publication.ts`): save as new template, or update the source at exactly the revision the copy last saw; stale revision is a 409 the user resolves. Every media reference in every workflow, active or not, joins reference indexing, cleanup protection, duplication and archive export. -`migrateProjectDocument` is the one migration boundary. Unversioned (schema 1) and schema 2 documents become schema 3 with their single `projectGraph` as first, active workflow; a `libraryWorkflowId` becomes a source with unknown revision, never assumed to match the library's current contents. Server loads, recovery drafts, duplication and `.invk` import all pass through it; every write path emits schema 3. Malformed collections (dangling active id, repeated ids, damaged entries) and structurally damaged workflow documents are refused, not repaired or replaced with blanks; refused document is reported, left untouched on the server, raw content stays exportable. Same-server duplication keeps source references; `.invk` import clears them, since a portable file never names a library write target. +`migrateProjectDocument` is the one migration boundary. Unversioned (schema 1) and schema 2 documents become schema 3 with their single `projectGraph` as first, active workflow; a `libraryWorkflowId` becomes a source with unknown revision, never assumed to match the library's current contents. Server loads (after joining a split project's halves), recovery drafts, duplication and `.invk` import all pass through it; every local document is schema 3. Every sync-engine save is the split of one, except a project whose workflow repeats an item id (above); renaming a closed project the server holds whole still writes a whole document, which the project's next save from an editor splits. `.invk` import (format v3: `project.json` content beside optional `workspace.json`, which becomes the importer's workspace) and duplication create the content and the creator's workspace in one request, the workspace already open on the inbox board the create claims: an inbox is always staged first (an empty one for a file without boards), so its id is known before the create and nothing is written after it but the other boards' placement. Malformed collections (dangling active id, repeated ids, damaged entries) and structurally damaged workflow documents are refused, not repaired or replaced with blanks; refused document is reported, left untouched on the server, raw content stays exportable. Same-server duplication keeps source references; `.invk` import clears them, since a portable file never names a library write target. Canvas uploads carrying a project id await that identity’s acknowledged server creation through the mounted persistence service. Uploads waiting on an unacknowledged project share one serialized creation attempt; a failed attempt answers further uploads for a few seconds before another is made; later uploads use the acknowledged identity without flushing unrelated edits. Only a closed project, account change, engine disposal or the operation's own cancellation stops an upload, and those stop the wait immediately. Any other outcome (creation failed or offline, conflict, or server that no longer knows the project and refuses the id) sends the upload without project id. Such uploads are never re-associated with the project later, so project's intermediates cleanup does not see them. @@ -28,7 +36,7 @@ Workbench persistence is scoped to one mounted Workbench and its account lifetim - Account-owned IndexedDB stores only unacknowledged project drafts, unload journal entries, active queue runs, receipt acknowledgements, bounded recall values. Clean server documents not mirrored. Journal has its own database, deleted with the workbench database by account cleanup and by clearing workspace data. - Draft generations fence acknowledgements. Draft writer ownership and cross-tab notifications stop one editor overwriting another editor's unacknowledged work. -- **Unload journal.** Staging a draft takes fenced read-then-write transactions, which an unloading page never finishes. So when page is hidden or unloads, before save starts, each open project whose document is not what next load would restore (its durably staged draft, or without one the acknowledged server copy) is written as one blind, explicitly committed IndexedDB write. Own database because Chromium drops such a write at unload while a read-write transaction of the same database is still open, which a stage in flight is. Entry carries account, project, lineage (an editor session, possibly another tab's that the session blob handed on), editor session of the page that wrote it, writer, base revision and canvas schema floor, and a draft generation: above everything the writer staged or journaled before, or equal to a still-in-flight stage of the same document. A writer's generations never restart while its page lives, even when a project is closed and reopened. Each write also deletes the writer's lower generations for the lineage, so at most one entry per lineage and writer, however long saves cannot be acknowledged. +- **Unload journal.** Staging a draft takes fenced read-then-write transactions, which an unloading page never finishes. So when page is hidden or unloads, before save starts, each open project whose document is not what next load would restore (its durably staged draft, or without one the acknowledged server copy) is written as one blind, explicitly committed IndexedDB write. Own database because Chromium drops such a write at unload while a read-write transaction of the same database is still open, which a stage in flight is. Entry carries account, project, lineage (an editor session, possibly another tab's that the session blob handed on), editor session of the page that wrote it, writer, base revisions and canvas schema floor, and a draft generation: above everything the writer staged or journaled before, or equal to a still-in-flight stage of the same document. A writer's generations never restart while its page lives, even when a project is closed and reopened. Each write also deletes the writer's lower generations for the lineage, so at most one entry per lineage and writer, however long saves cannot be acknowledged. - **Settled mark.** A lineage's writer claim records the generation through which its writer settled its journal: everything it acknowledged, discarded, or holds in recovery. Acknowledgement and draft deletion write it in the same transaction that settles or deletes the draft; a document back to what recovery holds (an undo, say) writes it on its next save, and claims the lineage for the writer if it has none. Optional claim field, so earlier claims read as settled nothing and workbench database stays at version 4; an earlier writer's mark is ignored. It tells "never staged" from "acknowledged and moved on" once an acknowledgement deleted the draft, so an entry whose deletion failed, or that another tab reconciles at the moment of acknowledgement, can never come back. - Next load of the account reconciles every entry, in pages, before any writer claims a lineage. Entry becomes its lineage's next draft only where its writer could still have staged it: lineage unclaimed or still claimed by that writer, writer has not settled that far, and lineage holds no generation at or above the entry's. It keeps the stored draft's conflict, schema refusal, copy reservation, and base revision, as staging does, then goes through usual load rules, so a server revision the base no longer matches surfaces as conflict. One exception: when the entry replaced a staged draft whose document the server now holds exactly (its save arrived, its answer did not), the draft is rebased onto that revision, as the late answer would have done, instead of conflicting with the writer's own save. Entry discarded when another writer adopted, reclaimed, or retargeted the lineage, when its writer settled it, when lineage already holds it or something newer, when it belongs to another account, or when its record schema is unknown or it is damaged. Draft written before entry removed, so a crash between leaves an entry the next pass discards as superseded. - Another tab's load reconciles only entries of pages that are gone. Page proves it still runs by its editor session's Web Lock, held until its last editor releases it or the page unloads or crashes; an entry whose writing page (the editor session it names, not the lineage it is written under) another page still holds is left in place (a merely hidden tab still writes its lineages, and staging its entry elsewhere would hand that tab's unsaved edit to another tab, where an undo could never retire it). Entry written before it named its page counts as a gone page's. Hidden page settles the entry with its own saves, or next load after it is gone recovers it. Browser that keeps a page holding its session in the back/forward cache keeps the lock held, so the entry waits until the page is restored (and saves) or evicted; one that evicts such pages (Chromium today) releases the lock, and the entry is recovered as a gone page's. Reload of same tab reclaims its session, retrying up to half a second (five retries 100 ms apart) while old document lets go, then reconciles its own entries. Two cases fall outside it. If old document holds the lock longer, reload takes a fresh session, its own entries and staged drafts count as a running page's on that load, and a later load recovers them once old document is gone. Duplicated tab waits the same half second before taking its own session; if the original reloads within it, the duplicate can take the original's session instead and then takes the original's entries and staged drafts, while reloaded original takes a fresh session. Staged drafts follow same rule: a load does not adopt, auto-open, or offer for recovery a draft whose lineage is named after another running page's session, and editor route does not count it. Draft records its lineage, not the page staging it, so it is judged by the session that lineage is named after: a lineage isolated for an unopenable draft names no page and is adopted as before. A lineage the session blob hands on (today only isolated ones) is not judged at all: loading page claims its handed-on lineage directly, whether or not the page staging it still runs. Without Web Locks (plain-HTTP deployment, say) liveness cannot be told, so every entry is reconciled and every draft adopted as before. Hidden event during save-as-new journals the source project: if page goes away before the copy exists, edit is recovered into the conflicted source and retried copy keeps its reserved document; once copy exists, source lineage has moved, so entry is discarded and copy is never written from it. diff --git a/invokeai/frontend/webv2/src/workbench/projects/api.test.ts b/invokeai/frontend/webv2/src/workbench/projects/api.test.ts index e50834a37e3..d6a14e0f3bc 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/api.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/api.test.ts @@ -21,10 +21,12 @@ import { deleteProject, createProject, createProjectSettled, + getProjectDocumentSchemaRefusal, getProjectWriteSizeRefusal, getProject, isProjectCanvasSchemaUnsupportedError, ProjectCreateAbsentError, + saveProjectWorkspace, updateProject, } from './api'; @@ -38,7 +40,12 @@ beforeEach(() => { describe('createProjectSettled', () => { const request = { data: {}, name: 'Imported', project_id: 'project-1' }; const post = () => ({ - body: JSON.stringify({ ...request, max_canvas_schema_version: 4, minimum_canvas_schema_version: 3 }), + body: JSON.stringify({ + ...request, + max_canvas_schema_version: 4, + max_document_schema_version: 4, + minimum_canvas_schema_version: 3, + }), method: 'POST', signal: expect.anything(), }); @@ -74,6 +81,7 @@ describe('createProjectSettled', () => { board_id: 'board-for-project-1', data: {}, minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, name: 'Imported', project_id: 'project-1', }); @@ -85,7 +93,7 @@ describe('createProjectSettled', () => { expect(transport.apiFetchJson).toHaveBeenNthCalledWith(2, '/api/v1/projects/', post()); expect(transport.apiFetchJson).toHaveBeenNthCalledWith( 3, - '/api/v1/projects/project-1?max_canvas_schema_version=4', + '/api/v1/projects/project-1?max_canvas_schema_version=4&max_document_schema_version=4', expect.anything() ); }); @@ -153,6 +161,7 @@ describe('createProjectSettled', () => { board_id: 'board-for-project-1', data: {}, minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, name: 'Imported', project_id: 'project-1', }; @@ -164,7 +173,7 @@ describe('createProjectSettled', () => { await expect(createProjectSettled(request, captureAccountScope())).resolves.toMatchObject(project); expect(transport.apiFetchJson).toHaveBeenNthCalledWith( 3, - '/api/v1/projects/project-1?max_canvas_schema_version=4', + '/api/v1/projects/project-1?max_canvas_schema_version=4&max_document_schema_version=4', expect.anything() ); @@ -255,21 +264,24 @@ describe('canvas schema compatibility declarations', () => { await getProject('project/one'); - expect(transport.apiFetchJson).toHaveBeenCalledWith('/api/v1/projects/project%2Fone?max_canvas_schema_version=4', { - signal: undefined, - }); + expect(transport.apiFetchJson).toHaveBeenCalledWith( + '/api/v1/projects/project%2Fone?max_canvas_schema_version=4&max_document_schema_version=4', + { signal: undefined } + ); }); it('declares both the written floor and supported maximum when creating', async () => { transport.apiFetchJson.mockResolvedValueOnce({ project_id: 'project-1' }); - await createProject({ data: {}, name: 'Project' }); + await createProject({ data: {}, minimum_document_schema_version: 4, name: 'Project' }); expect(transport.apiFetchJson).toHaveBeenCalledWith('/api/v1/projects/', { body: JSON.stringify({ data: {}, + minimum_document_schema_version: 4, name: 'Project', max_canvas_schema_version: 4, + max_document_schema_version: 4, minimum_canvas_schema_version: 3, }), method: 'POST', @@ -294,6 +306,7 @@ describe('canvas schema compatibility declarations', () => { minimum_canvas_schema_version: 3, name: 'Project', max_canvas_schema_version: 4, + max_document_schema_version: 4, }), method: 'PUT', signal: undefined, @@ -483,3 +496,150 @@ describe('deleteProject', () => { ); }); }); + +describe('project workspaces', () => { + it('saves the caller’s workspace at its own route, declaring the schemas it understands', async () => { + transport.apiFetchJson.mockResolvedValueOnce({ data: {}, revision: 2, updated_at: '' }); + + await saveProjectWorkspace('project/one', { data: { layout: {} }, expected_revision: 1 }); + + expect(transport.apiFetchJson).toHaveBeenCalledWith('/api/v1/projects/project%2Fone/workspace', { + body: JSON.stringify({ + data: { layout: {} }, + expected_revision: 1, + max_canvas_schema_version: 4, + max_document_schema_version: 4, + }), + method: 'PUT', + signal: undefined, + }); + }); + + it('sends a workspace to store with the content in the same update', async () => { + const workspace = { data: { layout: {} }, expected_revision: null }; + transport.apiFetchJson.mockResolvedValueOnce({ project_id: 'project-1', workspace: { ...workspace, revision: 1 } }); + + const updated = await updateProject('project-1', { + data: { id: 'project-1' }, + expected_revision: 1, + minimum_document_schema_version: 4, + name: 'Project', + workspace, + }); + + expect(updated.workspace).toMatchObject({ revision: 1 }); + expect(transport.apiFetchJson).toHaveBeenCalledWith('/api/v1/projects/project-1', { + body: JSON.stringify({ + data: { id: 'project-1' }, + expected_revision: 1, + minimum_document_schema_version: 4, + name: 'Project', + workspace, + max_canvas_schema_version: 4, + max_document_schema_version: 4, + }), + method: 'PUT', + signal: undefined, + }); + }); + + it('recognizes a refusal of a newer document schema, and only that', () => { + const refusalOf = (detail: Record) => new ApiError(JSON.stringify({ detail }), 412); + + expect( + getProjectDocumentSchemaRefusal( + refusalOf({ + code: 'document_schema_unsupported', + max_document_schema_version: 4, + minimum_document_schema_version: 5, + }) + ) + ).toEqual({ maxDocumentSchemaVersion: 4, minimumDocumentSchemaVersion: 5 }); + for (const detail of [ + { code: 'canvas_schema_unsupported', max_document_schema_version: 4, minimum_document_schema_version: 5 }, + { code: 'document_schema_unsupported', max_document_schema_version: 5, minimum_document_schema_version: 5 }, + ]) { + expect(getProjectDocumentSchemaRefusal(refusalOf(detail))).toBeNull(); + } + }); + + it('sends the caller’s first workspace with the content in one create, and answers with what it stored', async () => { + const workspace = { data: { layout: {} }, revision: 1, updated_at: '' }; + transport.apiFetchJson.mockResolvedValueOnce({ project_id: 'project-1', workspace }); + + const created = await createProject({ + data: {}, + minimum_document_schema_version: 4, + name: 'Project', + workspace: { layout: {} }, + }); + + expect(created.workspace).toEqual(workspace); + expect(transport.apiFetchJson).toHaveBeenCalledExactlyOnceWith('/api/v1/projects/', { + body: JSON.stringify({ + data: {}, + minimum_document_schema_version: 4, + name: 'Project', + workspace: { layout: {} }, + max_canvas_schema_version: 4, + max_document_schema_version: 4, + minimum_canvas_schema_version: 3, + }), + method: 'POST', + signal: undefined, + }); + }); + + it('adopts the workspace a retried create finds its first attempt stored with the content', async () => { + const workspace = { data: { layout: {} }, revision: 1, updated_at: '' }; + transport.apiFetchJson + .mockRejectedValueOnce(new TypeError('network error')) + .mockRejectedValueOnce(new ApiError('conflict', 409)) + .mockResolvedValueOnce({ + board_id: 'board', + data: {}, + minimum_canvas_schema_version: 3, + minimum_document_schema_version: 4, + name: 'Copy', + project_id: 'project-1', + workspace, + }); + + const created = await createProjectSettled( + { + data: {}, + minimum_document_schema_version: 4, + name: 'Copy', + project_id: 'project-1', + workspace: { layout: {} }, + }, + captureAccountScope() + ); + + // The workspace is the committed create's; the read is judged by the content it carries. + expect(created.workspace).toEqual(workspace); + expect(transport.apiFetchJson).toHaveBeenCalledTimes(3); + }); + + it('does not adopt a retried create whose project is still held whole when content was sent', async () => { + const conflict = new ApiError('conflict', 409); + transport.apiFetchJson + .mockRejectedValueOnce(new TypeError('network error')) + .mockRejectedValueOnce(conflict) + .mockResolvedValueOnce({ + board_id: 'board', + data: {}, + minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, + name: 'Imported', + project_id: 'project-1', + }); + + await expect( + createProjectSettled( + { data: {}, minimum_document_schema_version: 4, name: 'Imported', project_id: 'project-1' }, + captureAccountScope() + ) + ).rejects.toBe(conflict); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/api.ts b/invokeai/frontend/webv2/src/workbench/projects/api.ts index 80da305e59a..7d955386db9 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/api.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/api.ts @@ -8,6 +8,8 @@ import { MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, } from '@workbench/canvasSchemaVersion'; +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; + /** Project records and client state are scoped to the authenticated account. */ const PROJECTS_BASE = '/api/v1/projects'; @@ -23,14 +25,35 @@ export interface ProjectSummaryDTO { name: string; revision: number; minimum_canvas_schema_version: number; + /** The oldest project document schema a client must read; 3 for a project saved whole, 4 once split. */ + minimum_document_schema_version: number; created_at: string; + /** When the project content was last saved. */ updated_at: string; + /** When the caller last saved their workspace in the project; null before they have. */ + workspace_updated_at: string | null; } +export interface ProjectWorkspaceDTO { + data: Record; + revision: number; + updated_at: string; +} + +/** What a write answers: the project content (from document schema 4; the whole document before it). */ export interface ProjectRecordDTO extends ProjectSummaryDTO { data: Record; } +/** + * What a read answers: the content and the caller's workspace beside it, null until their first save. A create or an + * update answers the same shape, with the workspace it stored beside the content, or null when the request carried + * none. + */ +export interface ProjectWithWorkspaceDTO extends ProjectRecordDTO { + workspace: ProjectWorkspaceDTO | null; +} + export interface ProjectCreateRequest { project_id?: string; /** Adopt and rename an unclaimed private board, or omit to create one atomically with the project. */ @@ -39,6 +62,13 @@ export interface ProjectCreateRequest { data: Record; /** Raise the server's compatibility floor atomically with this document. */ minimum_canvas_schema_version?: number; + /** 4 when `data` is project content, which every save of a split project is; omitted for a document saved whole. */ + minimum_document_schema_version?: number; + /** + * The caller's first workspace in the project, stored in the same transaction as the content: the project never + * exists without it. Only beside split content (a document floor of 4); the server refuses it beside a whole one. + */ + workspace?: Record; } export type ProjectBoardItemKind = 'image' | 'video'; @@ -70,14 +100,28 @@ export interface ProjectUpdateRequest { expected_revision: number; /** Raise the compatibility floor atomically with this save; omission preserves the stored floor. */ minimum_canvas_schema_version?: number; + /** 4 when `data` is project content, which splits a project saved whole; omission preserves the stored floor. */ + minimum_document_schema_version?: number; + /** + * The caller's workspace, stored in the same transaction as the content: how a project the server holds whole is + * split without a moment in which it holds the content alone. A workspace revision mismatch refuses the content too. + */ + workspace?: ProjectWorkspaceSaveRequest | null; +} + +export interface ProjectWorkspaceSaveRequest { + data: Record; + /** The workspace revision this save replaces, or null for the caller's first workspace in the project. */ + expected_revision: number | null; } export const listProjects = (signal?: AbortSignal): Promise => apiFetchJson(`${PROJECTS_BASE}/`, { signal }); -export const getProject = (projectId: string, signal?: AbortSignal): Promise => - apiFetchJson( - `${PROJECTS_BASE}/${encodeURIComponent(projectId)}?max_canvas_schema_version=${MAX_SUPPORTED_CANVAS_SCHEMA_VERSION}`, +export const getProject = (projectId: string, signal?: AbortSignal): Promise => + apiFetchJson( + `${PROJECTS_BASE}/${encodeURIComponent(projectId)}?max_canvas_schema_version=${MAX_SUPPORTED_CANVAS_SCHEMA_VERSION}` + + `&max_document_schema_version=${PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION}`, { signal } ); @@ -150,9 +194,11 @@ const serializeCreateProjectRequest = (request: ProjectCreateRequest): string => JSON.stringify({ ...request, max_canvas_schema_version: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, + max_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, minimum_canvas_schema_version: request.minimum_canvas_schema_version ?? DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, }); +/** Judged by the content: a committed create stored its workspace with it, so the record found carries it too. */ const projectRecordMatchesCreate = (record: ProjectRecordDTO, requestBody: string): boolean => { const request = JSON.parse(requestBody) as ProjectCreateRequest; @@ -162,7 +208,8 @@ const projectRecordMatchesCreate = (record: ProjectRecordDTO, requestBody: strin JSON.stringify(record.data) === JSON.stringify(request.data) && (request.board_id === undefined || record.board_id === request.board_id) && record.minimum_canvas_schema_version >= - (request.minimum_canvas_schema_version ?? DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION) + (request.minimum_canvas_schema_version ?? DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION) && + record.minimum_document_schema_version >= (request.minimum_document_schema_version ?? 1) ); }; @@ -170,10 +217,10 @@ const createProjectFromBody = ( body: string, signal: AbortSignal | undefined, budget: ProjectWriteRetryBudget -): Promise => +): Promise => requestProjectWrite( () => - apiFetchJson(`${PROJECTS_BASE}/`, { + apiFetchJson(`${PROJECTS_BASE}/`, { body, method: 'POST', signal, @@ -182,19 +229,51 @@ const createProjectFromBody = ( budget ); -export const createProject = (request: ProjectCreateRequest, signal?: AbortSignal): Promise => +export const createProject = (request: ProjectCreateRequest, signal?: AbortSignal): Promise => createProjectFromBody(serializeCreateProjectRequest(request), signal, createProjectWriteRetryBudget()); export const updateProject = ( projectId: string, request: ProjectUpdateRequest, signal?: AbortSignal -): Promise => { - const body = JSON.stringify({ ...request, max_canvas_schema_version: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION }); +): Promise => { + const body = JSON.stringify({ + ...request, + max_canvas_schema_version: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, + max_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + }); return requestProjectWrite( () => - apiFetchJson(`${PROJECTS_BASE}/${encodeURIComponent(projectId)}`, { + apiFetchJson(`${PROJECTS_BASE}/${encodeURIComponent(projectId)}`, { + body, + method: 'PUT', + signal, + }), + signal, + createProjectWriteRetryBudget() + ); +}; + +/** + * Saves the caller's workspace in a project; its revision is its own, apart from the content's. Declares the schemas + * this build reads, as every project write does: a project whose canvas floor is above them is refused, not written + * beside a canvas this build could not have loaded. + */ +export const saveProjectWorkspace = ( + projectId: string, + request: ProjectWorkspaceSaveRequest, + signal?: AbortSignal +): Promise => { + const body = JSON.stringify({ + ...request, + max_canvas_schema_version: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, + max_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + }); + + return requestProjectWrite( + () => + apiFetchJson(`${PROJECTS_BASE}/${encodeURIComponent(projectId)}/workspace`, { body, method: 'PUT', signal, @@ -269,6 +348,31 @@ export const getProjectCanvasSchemaCompatibilityRefusal = ( } }; +export interface ProjectDocumentSchemaRefusal { + minimumDocumentSchemaVersion: number; + maxDocumentSchemaVersion: number; +} + +/** The server refused a project whose document schema this build predates: a newer Invoke split it further. */ +export const getProjectDocumentSchemaRefusal = (error: unknown): ProjectDocumentSchemaRefusal | null => { + if (!(error instanceof ApiError) || error.status !== 412) { + return null; + } + try { + const detail = (JSON.parse(error.message) as { detail?: Record }).detail; + const minimum = detail?.minimum_document_schema_version; + const maximum = detail?.max_document_schema_version; + return detail?.code === 'document_schema_unsupported' && + Number.isSafeInteger(minimum) && + Number.isSafeInteger(maximum) && + (minimum as number) > (maximum as number) + ? { maxDocumentSchemaVersion: maximum as number, minimumDocumentSchemaVersion: minimum as number } + : null; + } catch { + return null; + } +}; + /** The server refused to expose or replace a project written with a newer canvas schema. */ export const isProjectCanvasSchemaUnsupportedError = (error: unknown): boolean => getProjectCanvasSchemaCompatibilityRefusal(error) !== null; @@ -325,17 +429,24 @@ export class ProjectCreateAbsentError extends Error { /** * Settle ambiguous creates with a second POST: SQLite serializes writers, while GET 404 can race the initial - * commit. A 201 succeeds; after 409, GET distinguishes an existing project from a board conflict. Other - * deterministic rejections prove absence. When the retry settles nothing either, a GET that finds this create's - * project still proves it committed. Transport failure remains unknown and must never authorize deletion. + * commit. A 201 succeeds; after 409, GET distinguishes an existing project from a board conflict or an id another + * account holds. Other deterministic rejections prove absence. When the retry settles nothing either, a GET that finds + * this create's project still proves it committed. Transport failure remains unknown and must never authorize deletion. + * A read that settles the create carries the workspace the committed attempt stored, as the create's own answer would. */ -const settleProjectCreate = async (request: ProjectCreateRequest, owner: AccountScope): Promise => { +const settleProjectCreate = async ( + request: ProjectCreateRequest, + owner: AccountScope +): Promise => { const projectId = request.project_id; const body = serializeCreateProjectRequest(request); const writeBudget = createProjectWriteRetryBudget(); - /** The settled 409 makes this lookup decisive: an existing project committed; 404 means a board conflict. */ - const settleConflict = async (conflict: unknown): Promise => { + /** + * The settled 409 makes this lookup decisive: an existing project committed; 404 means a board conflict, or an id + * another account holds. + */ + const settleConflict = async (conflict: unknown): Promise => { if (projectId === undefined) { throw conflict; } @@ -356,8 +467,8 @@ const settleProjectCreate = async (request: ProjectCreateRequest, owner: Account }; /** No answer settled the create; only finding its project does. A 404 here may predate the commit, so proves nothing. */ - const settleUnanswered = async (unanswered: unknown, id: string): Promise => { - let record: ProjectRecordDTO | null = null; + const settleUnanswered = async (unanswered: unknown, id: string): Promise => { + let record: ProjectWithWorkspaceDTO | null = null; try { record = await getProject(id, owner.signal); @@ -373,7 +484,7 @@ const settleProjectCreate = async (request: ProjectCreateRequest, owner: Account return record; }; - const classify = (error: unknown): Promise => { + const classify = (error: unknown): Promise => { if (isProjectConflictError(error)) { return settleConflict(error); } @@ -409,7 +520,7 @@ const settleProjectCreate = async (request: ProjectCreateRequest, owner: Account export const createProjectSettled = async ( request: ProjectCreateRequest, owner: AccountScope -): Promise => { +): Promise => { const record = await settleProjectCreate(request, owner); // The server mints the project's board with it, so board lists fetched before now are missing it. diff --git a/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx b/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx index 6c93f48875d..d3dbaf1da04 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx @@ -1,4 +1,4 @@ -import type { ProjectRecordDTO } from '@workbench/projects/api'; +import type { ProjectWithWorkspaceDTO } from '@workbench/projects/api'; import { Icon, Portal, Spinner, Stack, Text } from '@chakra-ui/react'; import { useMountEffect } from '@platform/react/useMountEffect'; @@ -117,7 +117,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos ); const openImportedProject = useCallback( - (record: ProjectRecordDTO) => { + (record: ProjectWithWorkspaceDTO) => { const result = persistence.adoptProjectRecord(record); if (result.status === 'loaded') { diff --git a/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsDialog.tsx b/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsDialog.tsx index f76b1131e96..237721db57d 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsDialog.tsx @@ -1,11 +1,15 @@ import { Checkbox, Portal, Stack, Text } from '@chakra-ui/react'; import { Button, CloseButton } from '@platform/ui'; import { Dialog } from '@platform/ui/Dialog'; -import { useCallback, useState } from 'react'; +import { patchWorkbenchPreferences, useWorkbenchPreferenceSelector } from '@workbench/settings/store'; +import { useCallback, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { ProjectFileOptionsRequest } from './ProjectFileOptionsProvider'; +/** Each option's description starts under its label, past the checkbox and the gap after it. */ +const OPTION_DESCRIPTION_INSET = '6'; + export const ProjectFileOptionsDialog = ({ isOpen, onExitComplete, @@ -17,6 +21,12 @@ export const ProjectFileOptionsDialog = ({ }) => { const { t } = useTranslation(); const [includeFonts, setIncludeFonts] = useState(false); + const workspaceDescriptionId = useId(); + const fontsDescriptionId = useId(); + const rememberedIncludeWorkspace = useWorkbenchPreferenceSelector( + (preferences) => preferences.exportIncludesWorkspace + ); + const [includeWorkspace, setIncludeWorkspace] = useState(rememberedIncludeWorkspace); const close = useCallback(() => request.settle(null), [request]); const isExport = request.kind === 'export'; const returnFocus = useCallback( @@ -31,11 +41,20 @@ export const ProjectFileOptionsDialog = ({ }, [close] ); - const handleCheckedChange = useCallback( + const handleFontsCheckedChange = useCallback( ({ checked }: { checked: boolean | 'indeterminate' }) => setIncludeFonts(checked === true), [] ); - const confirm = useCallback(() => request.settle({ includeFonts }), [includeFonts, request]); + const handleWorkspaceCheckedChange = useCallback( + ({ checked }: { checked: boolean | 'indeterminate' }) => setIncludeWorkspace(checked === true), + [] + ); + const confirm = useCallback(() => { + if (isExport && includeWorkspace !== rememberedIncludeWorkspace) { + void patchWorkbenchPreferences({ exportIncludesWorkspace: includeWorkspace }); + } + request.settle({ includeFonts, includeWorkspace }); + }, [includeFonts, includeWorkspace, isExport, rememberedIncludeWorkspace, request]); return ( - + {isExport ? ( <> - - - - - - {t('projects.fonts.includeFiles')} - - - {t('projects.fonts.exportDescription')} - - {includeFonts ? {t('projects.fonts.rightsReminder')} : null} + + + + + + + {t('projects.exportOptions.includeWorkspace')} + + + {t('projects.exportOptions.workspaceDescription')} + + + + + + + + + {t('projects.fonts.includeFiles')} + + + {t('projects.fonts.exportDescription')} + + {includeFonts ? ( + + {t('projects.fonts.rightsReminder')} + + ) : null} + ) : ( {t('projects.fonts.quotaDescription')} diff --git a/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsProvider.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsProvider.browser.test.tsx index d38f23f2aaf..ac3e26165e4 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsProvider.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsProvider.browser.test.tsx @@ -1,3 +1,5 @@ +import type * as projectsApi from '@workbench/projects/api'; + import { ChakraProvider } from '@chakra-ui/react'; /* oxlint-disable react-perf/jsx-no-new-function-as-prop */ import { settleAnimations } from '@platform/browser/settleAnimations.testing'; @@ -5,15 +7,22 @@ import { accountLifecycle, captureAccountScope } from '@platform/state/accountLi import { closingFrames, recordDialogExit } from '@platform/ui/dialogExit.testing'; import { isModalPresent } from '@platform/ui/modalPresence'; import { system } from '@theme/system'; +import { getWorkbenchPreferences, patchWorkbenchPreferences } from '@workbench/settings/store'; import { createInstance } from 'i18next'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nextProvider, initReactI18next } from 'react-i18next'; -import { afterEach, expect, it } from 'vitest'; +import { afterEach, expect, it, vi } from 'vitest'; import { page } from 'vitest/browser'; import { ProjectFileOptionsProvider, useProjectFileOptions } from './ProjectFileOptionsProvider'; +// The preference store is real; only its backend write is stubbed. +vi.mock('@workbench/projects/api', async (importOriginal) => ({ + ...(await importOriginal()), + setClientStateValue: async () => {}, +})); + const i18n = createInstance(); await i18n.use(initReactI18next).init({ interpolation: { escapeValue: false }, @@ -23,7 +32,7 @@ await i18n.use(initReactI18next).init({ let root: Root | undefined; let host: HTMLDivElement | undefined; -let results: ({ includeFonts: boolean } | null)[] = []; +let results: ({ includeFonts: boolean; includeWorkspace: boolean } | null)[] = []; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const Harness = () => { @@ -73,7 +82,7 @@ it('defaults to references, includes files only when selected, and restores focu const checkbox = page.getByRole('checkbox', { name: 'Include font files' }); await expect.element(checkbox).not.toBeChecked(); await act(() => page.getByRole('button', { name: 'Export project', exact: true }).click()); - expect(results).toEqual([{ includeFonts: false }]); + expect(results).toEqual([{ includeFonts: false, includeWorkspace: true }]); await expect.element(trigger).toHaveFocus(); await act(() => trigger.click()); @@ -82,7 +91,33 @@ it('defaults to references, includes files only when selected, and restores focu await settleAnimations(); await page.screenshot({ path: '../../../../artifacts/fonts/export-dialog.png' }); await act(() => page.getByRole('button', { name: 'Export project', exact: true }).click()); - expect(results).toEqual([{ includeFonts: false }, { includeFonts: true }]); + expect(results).toEqual([ + { includeFonts: false, includeWorkspace: true }, + { includeFonts: true, includeWorkspace: true }, + ]); +}); + +it('includes my workspace unless I turn it off, and remembers that choice for the next export', async () => { + await render(); + const trigger = page.getByRole('button', { name: 'Export', exact: true }); + const checkbox = page.getByRole('checkbox', { name: 'Include my settings for this project' }); + try { + await act(() => trigger.click()); + await expect.element(checkbox).toBeChecked(); + await expect.element(checkbox).toHaveAccessibleDescription(/layout, generation settings, prompt history/); + await expect + .element(page.getByRole('checkbox', { name: 'Include font files' })) + .toHaveAccessibleDescription(/install or replace fonts/); + await act(() => page.getByText('Include my settings for this project', { exact: true }).click()); + await act(() => page.getByRole('button', { name: 'Export project', exact: true }).click()); + expect(results).toEqual([{ includeFonts: false, includeWorkspace: false }]); + expect(getWorkbenchPreferences().exportIncludesWorkspace).toBe(false); + + await act(() => trigger.click()); + await expect.element(checkbox).not.toBeChecked(); + } finally { + await act(() => patchWorkbenchPreferences({ exportIncludesWorkspace: true })); + } }); it('cancels a pending choice when its account goes away', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsProvider.tsx b/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsProvider.tsx index c9f0edbe89a..a304ef86d12 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsProvider.tsx +++ b/invokeai/frontend/webv2/src/workbench/projects/components/ProjectFileOptionsProvider.tsx @@ -16,6 +16,7 @@ import { interface ExportOptions { includeFonts: boolean; + includeWorkspace: boolean; } interface ProjectFileOptionsControl { diff --git a/invokeai/frontend/webv2/src/workbench/projects/documentSchemaVersion.ts b/invokeai/frontend/webv2/src/workbench/projects/documentSchemaVersion.ts new file mode 100644 index 00000000000..559ae18a52b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/documentSchemaVersion.ts @@ -0,0 +1,8 @@ +/** The whole-document schema the browser works in, and a project the server holds whole is saved in. */ +export const PROJECT_DOCUMENT_SCHEMA_VERSION = 3; + +/** + * The server's project format from document schema 4: the project content and each member's workspace, saved apart + * (`projectHalves.ts`). Every save this build makes is of that format, and it reads nothing newer. + */ +export const PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION = 4; diff --git a/invokeai/frontend/webv2/src/workbench/projects/draftStore.contract.ts b/invokeai/frontend/webv2/src/workbench/projects/draftStore.contract.ts index d05f9705cc0..9bdf838e559 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/draftStore.contract.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/draftStore.contract.ts @@ -169,7 +169,7 @@ export const testProjectDraftStoreContract = (createStore: () => Promise +>; export interface ProjectDraftInput { baseMinimumCanvasSchemaVersion?: number; + /** The project content's revision the draft builds on. */ baseRevision: number | null; + /** The caller's workspace revision the draft builds on; absent (from builds before the split) means none. */ + baseWorkspaceRevision?: number | null; documentJson: string; documentSchemaVersion: number; editorSessionId: string; @@ -137,6 +148,11 @@ export type ProjectDraftGetResult = | { kind: 'empty'; writerState: ProjectDraftWriterClaim['state']; writerToken: string } | { kind: 'retargeted'; projectId: string; revision: number; writerToken: string } | { kind: 'corrupt' | 'missing' | 'unavailable' }; +/** A rebase changes only the bases: the caller, which holds the draft, applies them itself (`withBases`). */ +export type ProjectDraftRebaseResult = + | { kind: 'rebased' } + | { kind: 'corrupt' | 'fenced' | 'missing' | 'quota' | 'stale' | 'unavailable' }; + export type ProjectDraftSettlementResult = | { draft: ProjectDraft; kind: 'marked' | 'rebased' } | { draft: ProjectDraft | null; kind: 'retargeted' } @@ -166,6 +182,7 @@ export interface ProjectUnloadJournalEntry { accountId: string; baseMinimumCanvasSchemaVersion?: number; baseRevision: number | null; + baseWorkspaceRevision?: number | null; documentByteSize: number; documentJson: string; documentSchemaVersion: number; @@ -240,6 +257,8 @@ export type ProjectUnloadJournalDecision = export interface RetargetAcknowledgedCopyOptions { acknowledgedRevision: number; + /** The copy's workspace revision; omitted, the draft keeps none. */ + acknowledgedWorkspaceRevision?: number | null; copyProjectId: string; editorSessionId: string; projectId: string; @@ -323,12 +342,25 @@ export interface ProjectDraftStore { proposed: ProjectDraftCopyReservation, replaceCopyProjectId?: string ): Promise; - resumeSchemaRefused( + /** A schema-refused or conflicted draft back to dirty: its refusal or conflict is resolved by keeping it. */ + resumeDraft( projectId: string, editorSessionId: string, writerToken: string, generation: number ): Promise; + /** + * The lineage's draft (at or above `generation`) rebased onto what the server acknowledged of one half or both, + * keeping its document and state: a half saved while the draft held newer edits, or a save that landed with its + * answer lost. Omitted bases are kept. + */ + rebaseDraft( + projectId: string, + editorSessionId: string, + writerToken: string, + generation: number, + bases: ProjectDraftBases + ): Promise; retargetAcknowledgedCopy(options: RetargetAcknowledgedCopyOptions): Promise; /** Also settles the writer's unload journal through `sentGeneration`, in the same transaction. */ settleAcknowledgement( @@ -337,7 +369,9 @@ export interface ProjectDraftStore { writerToken: string, sentGeneration: number, acknowledgedRevision: number, - acknowledgedMinimumCanvasSchemaVersion?: number + acknowledgedMinimumCanvasSchemaVersion?: number, + /** The workspace revision acknowledged with the content; undefined keeps the draft's. */ + acknowledgedWorkspaceRevision?: number | null ): Promise; settleConflict( projectId: string, @@ -406,6 +440,8 @@ const isPositiveInteger = (value: unknown): value is number => const isNonNegativeInteger = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0; +const isOptionalBaseRevision = (value: unknown): boolean => + value === undefined || value === null || isPositiveInteger(value); type ProjectDraftCandidate = Partial & { conflict?: { kind?: unknown; serverRevision?: unknown }; @@ -424,6 +460,7 @@ const isProjectDraftCandidate = (draft: ProjectDraftCandidate, requireDocument: const hasReservation = reservationMetadata.some((value) => value !== undefined); if ( !(draft.baseRevision === null || isPositiveInteger(draft.baseRevision)) || + !isOptionalBaseRevision(draft.baseWorkspaceRevision) || (draft.baseMinimumCanvasSchemaVersion !== undefined && !isPositiveInteger(draft.baseMinimumCanvasSchemaVersion)) || !isNonNegativeInteger(draft.documentByteSize) || (requireDocument && typeof draft.documentJson !== 'string') || @@ -481,6 +518,7 @@ export const isProjectDraftInput = (value: unknown): value is ProjectDraftInput const input = value as Partial; return ( (input.baseRevision === null || isPositiveInteger(input.baseRevision)) && + isOptionalBaseRevision(input.baseWorkspaceRevision) && (input.baseMinimumCanvasSchemaVersion === undefined || isPositiveInteger(input.baseMinimumCanvasSchemaVersion)) && typeof input.documentJson === 'string' && isPositiveInteger(input.documentSchemaVersion) && @@ -676,6 +714,12 @@ export const toDirtyProjectDraft = (draft: ProjectDraft, changes: Partial ({ + ...draft, + ...Object.fromEntries(Object.entries(bases).filter(([, value]) => value !== undefined)), +}); + export const toConflictProjectDraft = (draft: ProjectDraft, conflict: ProjectDraftConflict): ProjectDraft => { const next: Record = { ...draft, conflict, state: 'conflict' }; delete next.refusal; @@ -701,6 +745,7 @@ const isProjectUnloadJournalEntry = ( entry.schemaVersion === PROJECT_UNLOAD_JOURNAL_SCHEMA_VERSION && isNonEmptyString(entry.accountId) && (entry.baseRevision === null || isPositiveInteger(entry.baseRevision)) && + isOptionalBaseRevision(entry.baseWorkspaceRevision) && (entry.baseMinimumCanvasSchemaVersion === undefined || isPositiveInteger(entry.baseMinimumCanvasSchemaVersion)) && isPositiveInteger(entry.documentSchemaVersion) && isNonEmptyString(entry.editorSessionId) && @@ -816,6 +861,7 @@ export const decideUnloadJournalEntry = ({ : { baseMinimumCanvasSchemaVersion: entry.baseMinimumCanvasSchemaVersion }), ...documentFields, baseRevision: entry.baseRevision, + ...(entry.baseWorkspaceRevision === undefined ? {} : { baseWorkspaceRevision: entry.baseWorkspaceRevision }), editorSessionId: entry.editorSessionId, projectId: entry.projectId, state: 'dirty', @@ -842,7 +888,8 @@ export const createUnavailableProjectDraftStore = (): ProjectDraftStore => ({ reconcileUnloadJournal: () => Promise.resolve({ kind: 'unavailable' }), settleUnloadJournal: () => Promise.resolve({ kind: 'unavailable' }), reserveCopyIdentity: () => Promise.resolve({ kind: 'unavailable' }), - resumeSchemaRefused: () => Promise.resolve({ kind: 'unavailable' }), + rebaseDraft: () => Promise.resolve({ kind: 'unavailable' }), + resumeDraft: () => Promise.resolve({ kind: 'unavailable' }), retargetAcknowledgedCopy: () => Promise.resolve({ kind: 'unavailable' }), settleAcknowledgement: () => Promise.resolve({ kind: 'unavailable' }), settleConflict: () => Promise.resolve({ kind: 'unavailable' }), @@ -1289,7 +1336,18 @@ export const createMemoryProjectDraftStore = ({ bumpWriterClaim(draftKey(projectId, editorSessionId)); return Promise.resolve({ ...reservation, kind: 'reserved' }); }, - resumeSchemaRefused(projectId, editorSessionId, writerToken, generation) { + rebaseDraft(projectId, editorSessionId, writerToken, generation, bases) { + const settled = settle( + projectId, + editorSessionId, + writerToken, + generation, + (draft) => withBases(draft, bases), + 'rebased' + ); + return Promise.resolve(settled.kind === 'rebased' ? { kind: 'rebased' } : (settled as ProjectDraftRebaseResult)); + }, + resumeDraft(projectId, editorSessionId, writerToken, generation) { return Promise.resolve( settle(projectId, editorSessionId, writerToken, generation, (draft) => toDirtyProjectDraft(draft, {}), 'marked') ); @@ -1363,6 +1421,7 @@ export const createMemoryProjectDraftStore = ({ ? null : toDirtyProjectDraft(record, { baseRevision: options.acknowledgedRevision, + baseWorkspaceRevision: options.acknowledgedWorkspaceRevision, copyDocumentByteSize: undefined, copyDocumentJson: undefined, copyProjectId: undefined, @@ -1406,7 +1465,8 @@ export const createMemoryProjectDraftStore = ({ writerToken, sentGeneration, acknowledgedRevision, - acknowledgedMinimumCanvasSchemaVersion + acknowledgedMinimumCanvasSchemaVersion, + acknowledgedWorkspaceRevision ) { if (isClosed) { return Promise.resolve({ kind: 'unavailable' }); @@ -1431,6 +1491,9 @@ export const createMemoryProjectDraftStore = ({ ...record, baseMinimumCanvasSchemaVersion: acknowledgedMinimumCanvasSchemaVersion ?? record.baseMinimumCanvasSchemaVersion, baseRevision: acknowledgedRevision, + ...(acknowledgedWorkspaceRevision === undefined + ? {} + : { baseWorkspaceRevision: acknowledgedWorkspaceRevision }), }); bumpWriterClaim(draftKey(projectId, editorSessionId)); return Promise.resolve({ draft, kind: 'rebased' }); diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts index 69de3803b7f..a6e4d52522d 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts @@ -4,10 +4,14 @@ import type { Project, WorkbenchState } from '@workbench/projectContracts'; import { accountLifecycle, captureAccountScope } from '@platform/state/accountLifecycle'; import { ApiError } from '@platform/transport/http'; import { createAccountOwnedQueueRunJournal } from '@workbench/queue-integration/queueRunJournal'; -import { createDraftProject, createInitialWorkbenchState } from '@workbench/workbenchState'; +import { + createDraftProject, + createInitialWorkbenchState, + withAuthoritativeProjectBoard, +} from '@workbench/workbenchState'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ProjectRecordDTO, ProjectSummaryDTO } from './api'; +import type { ProjectSummaryDTO, ProjectWithWorkspaceDTO } from './api'; import type { ProjectDraftStore } from './draftStore'; import type { WorkbenchSessionBlob } from './session'; @@ -25,6 +29,8 @@ import { } from './durableSyncedPersistence'; import { createDeterministicProjectId } from './ids'; import { serializeProjectDocumentV3, serializeProjectDocumentV3Json } from './projectDocument'; +import { splitProjectDocument } from './projectHalves'; +import { deserializeProjectRecord } from './projectHydration'; import { acquireProjectMutationLock } from './projectLifecycleLocks'; import { getProjectSyncSnapshot, registerOpenProject, unregisterOpenProject } from './syncStore'; @@ -57,42 +63,72 @@ const stateWith = (projects: Project[]): WorkbenchState => ({ projects, }); -const toRecord = (project: Project, revision = 1): ProjectRecordDTO => ({ +/** A project as a build before the split saved it: one whole document. */ +const toRecord = (project: Project, revision = 1): ProjectWithWorkspaceDTO => ({ board_id: `board-${project.id}`, created_at: now, data: serializeProjectDocumentV3(project), minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, name: project.name, project_id: project.id, revision, updated_at: now, + workspace: null, + workspace_updated_at: null, }); -const toSummary = (record: ProjectRecordDTO): ProjectSummaryDTO => ({ +/** A project as this build saves it: content, and the caller's workspace beside it. */ +const toSplitRecord = (project: Project, revision = 1, workspaceRevision = 1): ProjectWithWorkspaceDTO => { + const halves = splitProjectDocument(serializeProjectDocumentV3(project)); + return { + ...toRecord(project, revision), + data: halves.content as unknown as Record, + minimum_document_schema_version: 4, + workspace: { + data: halves.workspace as unknown as Record, + revision: workspaceRevision, + updated_at: now, + }, + workspace_updated_at: now, + }; +}; + +/** What the server answers a create of `project` with: its content, and no workspace yet. */ +const toSummary = (record: ProjectWithWorkspaceDTO): ProjectSummaryDTO => ({ board_id: record.board_id, created_at: record.created_at, minimum_canvas_schema_version: record.minimum_canvas_schema_version, + minimum_document_schema_version: record.minimum_document_schema_version, name: record.name, project_id: record.project_id, revision: record.revision, updated_at: record.updated_at, + workspace_updated_at: record.workspace_updated_at, }); const createApi = () => { - const records = new Map(); - const api: DurableProjectPersistenceApi & { records: Map } = { + const records = new Map(); + const api: DurableProjectPersistenceApi & { records: Map } = { records, createProject: vi.fn((request) => { const id = request.project_id!; - const record: ProjectRecordDTO = { + // The caller's first workspace is stored with the content, and the answer carries it. + const workspace = request.workspace + ? { data: structuredClone(request.workspace), revision: 1, updated_at: now } + : null; + const record: ProjectWithWorkspaceDTO = { board_id: `board-${id}`, created_at: now, data: structuredClone(request.data), minimum_canvas_schema_version: request.minimum_canvas_schema_version ?? 3, + minimum_document_schema_version: request.minimum_document_schema_version ?? 3, name: request.name, project_id: id, revision: 1, updated_at: now, + workspace, + workspace_updated_at: workspace ? now : null, }; records.set(id, record); return Promise.resolve(structuredClone(record)); @@ -111,6 +147,22 @@ const createApi = () => { }), loadSession: vi.fn(() => Promise.resolve(null)), listProjects: vi.fn(() => Promise.resolve([...records.values()].map(toSummary))), + saveProjectWorkspace: vi.fn((projectId, request) => { + const current = records.get(projectId); + if (!current) { + return Promise.reject(Object.assign(new Error('not found'), { status: 404 })); + } + if ((current.workspace?.revision ?? null) !== request.expected_revision) { + return Promise.reject(Object.assign(new Error('conflict'), { status: 409 })); + } + const workspace = { + data: structuredClone(request.data), + revision: (current.workspace?.revision ?? 0) + 1, + updated_at: now, + }; + records.set(projectId, { ...current, workspace, workspace_updated_at: now }); + return Promise.resolve(structuredClone(workspace)); + }), saveSession: vi.fn(() => Promise.resolve()), updateProject: vi.fn((projectId, request) => { const current = records.get(projectId); @@ -120,19 +172,36 @@ const createApi = () => { if (current.revision !== request.expected_revision) { return Promise.reject(Object.assign(new Error('conflict'), { status: 409 })); } - const record: ProjectRecordDTO = { + // A workspace sent with the content is stored in the same step, or refuses the content with it. + if (request.workspace && (current.workspace?.revision ?? null) !== request.workspace.expected_revision) { + return Promise.reject(Object.assign(new Error('conflict'), { status: 409 })); + } + const workspace = request.workspace + ? { + data: structuredClone(request.workspace.data), + revision: (current.workspace?.revision ?? 0) + 1, + updated_at: now, + } + : null; + const record: ProjectWithWorkspaceDTO = { ...current, data: structuredClone(request.data), minimum_canvas_schema_version: Math.max( current.minimum_canvas_schema_version, request.minimum_canvas_schema_version ?? current.minimum_canvas_schema_version ), + minimum_document_schema_version: Math.max( + current.minimum_document_schema_version, + request.minimum_document_schema_version ?? current.minimum_document_schema_version + ), name: request.name, revision: current.revision + 1, updated_at: now, + ...(workspace ? { workspace, workspace_updated_at: now } : {}), }; records.set(projectId, record); - return Promise.resolve(structuredClone(record)); + // The answer names only the workspace this write stored. + return Promise.resolve({ ...structuredClone(record), workspace: structuredClone(workspace) }); }), }; return api; @@ -189,8 +258,8 @@ describe('durable project persistence', () => { const api = createApi(); const project = createDraftProject([]); const service = createService(captureAccountScope(), api); - let acknowledge!: (record: ProjectRecordDTO) => void; - const created = new Promise((resolve) => { + let acknowledge!: (record: ProjectWithWorkspaceDTO) => void; + const created = new Promise((resolve) => { acknowledge = resolve; }); vi.mocked(api.createProject).mockReturnValueOnce(created); @@ -201,7 +270,8 @@ describe('durable project persistence', () => { await vi.waitFor(() => expect(api.createProject).toHaveBeenCalledOnce()); expect(ready).not.toHaveBeenCalled(); - acknowledge(toRecord(project)); + api.records.set(project.id, toSplitRecord(project)); + acknowledge(toSplitRecord(project)); await Promise.all([first, second]); await service.ensureProjectOnServer({ ...project, name: 'Unsaved later edit' }); expect(api.createProject).toHaveBeenCalledOnce(); @@ -226,7 +296,7 @@ describe('durable project persistence', () => { const api = createApi(); const project = createDraftProject([]); const service = createService(captureAccountScope(), api); - let acknowledge!: (record: ProjectRecordDTO) => void; + let acknowledge!: (record: ProjectWithWorkspaceDTO) => void; vi.mocked(api.createProject).mockReturnValueOnce( new Promise((resolve) => { acknowledge = resolve; @@ -236,7 +306,7 @@ describe('durable project persistence', () => { const rejected = expect(pending).rejects.toMatchObject({ reason: 'superseded' }); await vi.waitFor(() => expect(api.createProject).toHaveBeenCalledOnce()); service.markProjectDeleted(project.id); - acknowledge(toRecord(project)); + acknowledge(toSplitRecord(project)); await rejected; }); @@ -1682,7 +1752,7 @@ describe('durable project persistence', () => { const draftStore = createMemoryProjectDraftStore(); const project = createDraftProject([]); const futureRecord = toRecord(project, 2); - futureRecord.data = { ...futureRecord.data, documentSchemaVersion: 4 }; + futureRecord.data = { ...futureRecord.data, documentSchemaVersion: 5 }; api.records.set(project.id, futureRecord); await draftStore.stage({ baseRevision: 1, @@ -1710,7 +1780,7 @@ describe('durable project persistence', () => { expect(api.updateProject).not.toHaveBeenCalled(); await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ draft: { - refusal: { documentSchemaVersion: 4, kind: 'document', maxDocumentSchemaVersion: 3 }, + refusal: { documentSchemaVersion: 5, kind: 'document', maxDocumentSchemaVersion: 4 }, state: 'schema-refused', }, kind: 'found', @@ -1835,8 +1905,25 @@ describe('durable project persistence', () => { await service.saveWorkbench(loaded.state); await service.saveWorkbench(loaded.state); + // Split once, by one request: the content raises the document floor, and the caller's first workspace is stored + // with it, so the server never holds the content alone. + expect(api.updateProject).toHaveBeenCalledOnce(); + expect(vi.mocked(api.updateProject).mock.calls[0]![1]).toMatchObject({ + data: { documentSchemaVersion: 4 }, + minimum_document_schema_version: 4, + workspace: { data: { documentSchemaVersion: 4 }, expected_revision: null }, + }); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(api.records.get(project.id)).toMatchObject({ + minimum_document_schema_version: 4, + workspace: { revision: 1 }, + }); + // Both halves acknowledged: a later edit to the workspace alone builds on the stored one. + const edited = { ...loaded.state.projects[0]!, promptHistory: [{ negativePrompt: null, positivePrompt: 'later' }] }; + await service.saveWorkbench(stateWith([edited])); expect(api.updateProject).toHaveBeenCalledOnce(); - expect(vi.mocked(api.updateProject).mock.calls[0]![1].data.documentSchemaVersion).toBe(3); + expect(vi.mocked(api.saveProjectWorkspace).mock.calls.map(([, request]) => request.expected_revision)).toEqual([1]); + expect(api.records.get(project.id)).toMatchObject({ revision: 2, workspace: { revision: 2 } }); }); it.each([ @@ -2176,7 +2263,7 @@ describe('durable project persistence', () => { openProjectIds: [project.id], }); const responseLost = new TypeError('response lost'); - let committedCopy: ProjectRecordDTO | null = null; + let committedCopy: ProjectWithWorkspaceDTO | null = null; const saveDraftAsNew = vi.fn().mockImplementation((input) => { if (!committedCopy) { committedCopy = { @@ -2184,10 +2271,13 @@ describe('durable project persistence', () => { created_at: now, data: structuredClone(input.document), minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + minimum_document_schema_version: 3, name: input.name, project_id: input.copyProjectId, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }; api.records.set(input.copyProjectId, committedCopy); return Promise.reject(responseLost); @@ -2269,15 +2359,18 @@ describe('durable project persistence', () => { const saveDraftAsNew = vi.fn(async (input) => { signalCreateStarted(); await createReleased; - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { board_id: `board-${input.copyProjectId}`, created_at: now, data: structuredClone(input.document), minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + minimum_document_schema_version: 3, name: input.name, project_id: input.copyProjectId, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }; api.records.set(record.project_id, record); return record; @@ -2310,7 +2403,8 @@ describe('durable project persistence', () => { await saving; expect(api.records.get(copy.targetProjectId)?.name).toBe(`${local.name} (copy)`); - expect(api.records.get(copy.targetProjectId)?.data.settings).toEqual( + // Settings are the workspace's. + expect(api.records.get(copy.targetProjectId)?.workspace?.data.settings).toEqual( serializeProjectDocumentV3(backgroundEdit).settings ); }); @@ -3053,7 +3147,7 @@ describe('durable project persistence', () => { const draftStore: ProjectDraftStore = { ...backingStore, stage }; const project = createDraftProject([]); const remote = { ...project, name: 'remote edit' }; - api.records.set(project.id, toRecord(remote, 2)); + api.records.set(project.id, toSplitRecord(remote, 2)); await draftStore.stage({ baseRevision: 1, documentJson: JSON.stringify(serializeProjectDocumentV3({ ...project, name: 'discarded local edit' })), @@ -3268,15 +3362,18 @@ describe('durable project persistence', () => { openProjectIds: [project.id], }); const saveDraftAsNew = vi.fn((input) => { - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { board_id: `board-${input.copyProjectId}`, created_at: now, data: structuredClone(input.document), minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + minimum_document_schema_version: 3, name: input.name, project_id: input.copyProjectId, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }; api.records.set(record.project_id, record); return Promise.resolve(record); @@ -3592,3 +3689,902 @@ describe('unload journal', () => { expect(reloaded.conflicts).toEqual([]); }); }); + +describe('project content and the caller’s workspace', () => { + const openSplitProject = async (record: ProjectWithWorkspaceDTO, draftStore = createMemoryProjectDraftStore()) => { + const owner = captureAccountScope(); + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(owner, api, draftStore); + const loaded = await service.loadWorkbench(); + return { api, conflicts: loaded.conflicts, project: loaded.state.projects[0]!, service }; + }; + const withOtherSettings = (project: Project): Project => ({ + ...project, + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }); + + it('saves a change to how one works on the project as the workspace alone', async () => { + const { api, project, service } = await openSplitProject(toSplitRecord(createDraftProject([]))); + + await service.saveWorkbench(stateWith([withOtherSettings(project)])); + + expect(api.updateProject).not.toHaveBeenCalled(); + expect(vi.mocked(api.saveProjectWorkspace).mock.calls.map(([, request]) => request.expected_revision)).toEqual([1]); + expect(api.records.get(project.id)).toMatchObject({ revision: 1, workspace: { revision: 2 } }); + }); + + it('saves a change to the shared project as the content alone', async () => { + const { api, project, service } = await openSplitProject(toSplitRecord(createDraftProject([]))); + + await service.saveWorkbench(stateWith([{ ...project, name: 'Renamed' }])); + + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(vi.mocked(api.updateProject).mock.calls.map(([, request]) => request.expected_revision)).toEqual([1]); + expect(api.records.get(project.id)).toMatchObject({ name: 'Renamed', revision: 2, workspace: { revision: 1 } }); + }); + + it('replays a workspace a closed page never saved after its content, without a conflict', async () => { + const project = { ...createDraftProject([]), name: 'Saved before closing' }; + const draftStore = createMemoryProjectDraftStore(); + // The page saved the content (revision 2) and was gone before saving the workspace or hearing back. + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify(serializeProjectDocumentV3(withOtherSettings(project))), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + + const { + api, + conflicts, + project: loaded, + service, + } = await openSplitProject(toSplitRecord(project, 2, 1), draftStore); + await service.saveWorkbench(stateWith([loaded])); + + expect(conflicts).toEqual([]); + expect(api.updateProject).not.toHaveBeenCalled(); + expect(api.records.get(project.id)?.workspace).toMatchObject({ + data: { settings: withOtherSettings(project).settings }, + revision: 2, + }); + }); + + it('reports a workspace saved meanwhile from another tab as a conflict of the workspace alone', async () => { + const project = createDraftProject([]); + const draftStore = createMemoryProjectDraftStore(); + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify(serializeProjectDocumentV3(withOtherSettings(project))), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + const elsewhere = { ...project, promptHistory: [{ negativePrompt: null, positivePrompt: 'from another tab' }] }; + + const { conflicts } = await openSplitProject(toSplitRecord(elsewhere, 1, 2), draftStore); + + expect(conflicts).toMatchObject([{ half: 'workspace', kind: 'revision', projectId: project.id }]); + }); + + it('stops saving a project a newer Invoke split further, keeping the local work', async () => { + const draftStore = createMemoryProjectDraftStore(); + const { api, project, service } = await openSplitProject(toSplitRecord(createDraftProject([])), draftStore); + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce( + new ApiError( + JSON.stringify({ + detail: { + code: 'document_schema_unsupported', + max_document_schema_version: 4, + minimum_document_schema_version: 5, + }, + }), + 412 + ) + ); + + const saved = await service.saveWorkbench(stateWith([withOtherSettings(project)])); + + expect(saved.hasPendingChanges).toBe(true); + await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ + draft: { + refusal: { documentSchemaVersion: 5, kind: 'document', maxDocumentSchemaVersion: 4 }, + state: 'schema-refused', + }, + }); + }); +}); + +describe('a push that saved one half and not the other', () => { + it('still saves the content when an undo returns to what was last acknowledged whole', async () => { + const owner = captureAccountScope(); + const api = createApi(); + const project = createDraftProject([]); + api.records.set(project.id, toSplitRecord(project)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(owner, api); + const loaded = (await service.loadWorkbench()).state.projects[0]!; + // An edit to both halves: the content lands, the workspace does not. + const edited = { + ...loaded, + name: 'Edited', + settings: { ...loaded.settings, useCpuNoise: !loaded.settings.useCpuNoise }, + }; + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench(stateWith([edited])); + expect(api.records.get(project.id)?.name).toBe('Edited'); + + // Undone before the retry: the server holds the edited content, so it must be saved back. + await service.saveWorkbench(stateWith([loaded])); + + expect(api.records.get(project.id)).toMatchObject({ name: loaded.name, revision: 3 }); + }); +}); + +describe('recovering a project whose halves were acknowledged apart', () => { + const openWith = async ( + api: ReturnType, + projectId: string, + draftStore: ProjectDraftStore = createMemoryProjectDraftStore() + ) => { + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: projectId, + openProjectIds: [projectId], + }); + const service = createService(captureAccountScope(), api, draftStore); + const loaded = await service.loadWorkbench(); + return { conflicts: loaded.conflicts, project: loaded.state.projects[0]!, service }; + }; + + it('builds on its own content save when the workspace save after it failed and the page went away', async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const project = createDraftProject([]); + api.records.set(project.id, toSplitRecord(project)); + const { project: loaded, service } = await openWith(api, project.id, draftStore); + // The content lands; the workspace save after it does not. + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench( + stateWith([ + { ...loaded, name: 'First', settings: { ...loaded.settings, useCpuNoise: !loaded.settings.useCpuNoise } }, + ]) + ); + expect(api.records.get(project.id)).toMatchObject({ name: 'First', revision: 2 }); + // A further edit reaches only the draft before the page goes away. + vi.mocked(api.updateProject).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench(stateWith([{ ...loaded, name: 'Second' }])); + + const reloaded = await openWith(api, project.id, draftStore); + + expect(reloaded.conflicts).toEqual([]); + await reloaded.service.saveWorkbench(stateWith([reloaded.project])); + expect(api.records.get(project.id)).toMatchObject({ name: 'Second', revision: 3 }); + }); + + it('keeps this tab’s project edits when a workspace conflict is resolved with the saved workspace', async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const project = createDraftProject([]); + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify( + serializeProjectDocumentV3({ + ...project, + name: 'Edited here', + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }) + ), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + const elsewhere = { ...project, promptHistory: [{ negativePrompt: null, positivePrompt: 'from another tab' }] }; + api.records.set(project.id, toSplitRecord(elsewhere, 1, 2)); + const { conflicts, service } = await openWith(api, project.id, draftStore); + expect(conflicts).toMatchObject([{ half: 'workspace' }]); + + const resolved = await service.resolveConflictUseServer(project.id); + service.acknowledgeProjectResolution(project.id); + if (resolved.status !== 'loaded') { + throw new Error('Expected the resolution to load.'); + } + await service.saveWorkbench(stateWith([resolved.project])); + + expect(resolved.project).toMatchObject({ name: 'Edited here', promptHistory: elsewhere.promptHistory }); + expect(api.records.get(project.id)).toMatchObject({ name: 'Edited here', revision: 2, workspace: { revision: 2 } }); + }); +}); + +describe('a save the server answers with a revision conflict', () => { + const open = async (record: ProjectWithWorkspaceDTO) => { + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api); + const project = (await service.loadWorkbench()).state.projects[0]!; + return { api, project, service }; + }; + const prompted = (project: Project, prompt: string): Project => ({ + ...project, + promptHistory: [{ negativePrompt: null, positivePrompt: prompt }], + }); + const conflict = () => Object.assign(new Error('conflict'), { status: 409 }); + + it('reports another tab’s workspace as a conflict of the workspace, and sends it nothing more', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toSplitRecord(project)); + // Another tab saves its own workspace meanwhile. + const elsewhere = toSplitRecord(prompted(project, 'another tab'), 1, 2); + api.records.set(project.id, elsewhere); + + const saved = await service.saveWorkbench(stateWith([prompted(loaded, 'this tab')])); + + expect(saved.conflicts).toMatchObject([{ half: 'workspace', kind: 'revision' }]); + expect(api.saveProjectWorkspace).toHaveBeenCalledOnce(); + expect(api.records.get(project.id)?.workspace).toEqual(elsewhere.workspace); + }); + + it('acknowledges a workspace the server already holds as this tab’s', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toSplitRecord(project)); + const edited = prompted(loaded, 'this tab'); + // The same bytes reached the server another way (another tab of this account typed the same). + api.records.set(project.id, toSplitRecord(edited, 1, 2)); + + const saved = await service.saveWorkbench(stateWith([edited])); + + expect(saved.conflicts).toEqual([]); + expect(saved.hasPendingChanges).toBe(false); + expect(api.saveProjectWorkspace).toHaveBeenCalledOnce(); + }); + + it('retries a content save once when only the revision moved, and calls a second refusal a conflict', async () => { + const project = createDraftProject([]); + const retried = await open(toSplitRecord(project)); + // A save of the same bytes moved the revision on. + retried.api.records.set(project.id, toSplitRecord(project, 2)); + await retried.service.saveWorkbench(stateWith([{ ...retried.project, name: 'Renamed' }])); + expect(retried.api.updateProject).toHaveBeenCalledTimes(2); + expect(retried.api.records.get(project.id)).toMatchObject({ name: 'Renamed', revision: 3 }); + + const refused = await open(toSplitRecord(project)); + vi.mocked(refused.api.updateProject).mockRejectedValueOnce(conflict()).mockRejectedValueOnce(conflict()); + const saved = await refused.service.saveWorkbench(stateWith([{ ...refused.project, name: 'Renamed' }])); + expect(refused.api.updateProject).toHaveBeenCalledTimes(2); + expect(saved.conflicts).toMatchObject([{ kind: 'revision' }]); + expect(saved.conflicts[0]).not.toHaveProperty('half'); + }); + + it('retries the split of a project the server holds whole when only its revision moved', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toRecord(project)); + api.records.set(project.id, toRecord(project, 2)); + + const saved = await service.saveWorkbench(stateWith([{ ...loaded, name: 'Renamed' }])); + + expect(saved.conflicts).toEqual([]); + // Refused once, read back as still whole, then split again by one request: never the content on its own. + expect(vi.mocked(api.updateProject).mock.calls.map(([, request]) => request.expected_revision)).toEqual([1, 2]); + expect(vi.mocked(api.updateProject).mock.calls.every(([, request]) => request.workspace)).toBe(true); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(api.records.get(project.id)).toMatchObject({ + minimum_document_schema_version: 4, + name: 'Renamed', + revision: 3, + workspace: { revision: 1 }, + }); + }); + + it('reports a workspace another tab stored under the whole project as a conflict of the workspace', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toRecord(project)); + // A split by another tab of this account landed meanwhile, with its own workspace. + api.records.set(project.id, toSplitRecord(prompted(project, 'another tab'), 2, 1)); + + const saved = await service.saveWorkbench(stateWith([prompted(loaded, 'this tab')])); + + expect(saved.conflicts).toMatchObject([{ half: 'workspace', kind: 'revision' }]); + expect(api.updateProject).toHaveBeenCalledOnce(); + expect(api.records.get(project.id)).toMatchObject({ revision: 2, workspace: { revision: 1 } }); + }); +}); + +describe('splitting a project the server holds whole', () => { + const open = async (record: ProjectWithWorkspaceDTO) => { + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api); + const project = (await service.loadWorkbench()).state.projects[0]!; + return { api, project, service }; + }; + + it('leaves the server with both halves, never the content alone, when the account ends as the split lands', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toRecord(project)); + const store = vi.mocked(api.updateProject).getMockImplementation()!; + vi.mocked(api.updateProject).mockImplementationOnce(async (projectId, request, signal) => { + const answer = await store(projectId, request, signal); + // Signed out as the answer arrives: the account's drafts are deleted, and nothing more may be sent for it. + accountLifecycle.activate('another-account'); + return answer; + }); + + const saved = await service.saveWorkbench(stateWith([{ ...loaded, name: 'Renamed' }])); + + expect(saved.error).toMatch(/account scope/); + expect(api.updateProject).toHaveBeenCalledOnce(); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(api.records.get(project.id)).toMatchObject({ + minimum_document_schema_version: 4, + name: 'Renamed', + workspace: { data: { documentSchemaVersion: 4, settings: loaded.settings }, revision: 1 }, + }); + }); + + it('keeps the whole document when the account ends before the split is sent', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toRecord(project)); + const before = structuredClone(api.records.get(project.id)); + vi.mocked(api.updateProject).mockImplementationOnce(() => { + accountLifecycle.activate('another-account'); + return Promise.reject(new DOMException('Aborted', 'AbortError')); + }); + + const saved = await service.saveWorkbench(stateWith([{ ...loaded, name: 'Renamed' }])); + + expect(saved.error).toMatch(/account scope/); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(api.records.get(project.id)).toEqual(before); + }); + + it('sends the workspace on its own when the split’s answer names none', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toRecord(project)); + // A server that stored the content and ignored the workspace sent with it. + const store = vi.mocked(api.updateProject).getMockImplementation()!; + vi.mocked(api.updateProject).mockImplementationOnce((projectId, request, signal) => { + const { workspace: _workspace, ...content } = request; + return store(projectId, content, signal); + }); + + const saved = await service.saveWorkbench(stateWith([{ ...loaded, name: 'Renamed' }])); + + expect(saved.conflicts).toEqual([]); + expect(saved.hasPendingChanges).toBe(false); + expect(api.updateProject).toHaveBeenCalledOnce(); + expect(vi.mocked(api.saveProjectWorkspace).mock.calls.map(([, request]) => request.expected_revision)).toEqual([ + null, + ]); + expect(api.records.get(project.id)).toMatchObject({ + minimum_document_schema_version: 4, + name: 'Renamed', + revision: 2, + workspace: { revision: 1 }, + }); + }); +}); + +describe('saving the two halves', () => { + const open = async (record: ProjectWithWorkspaceDTO) => { + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api); + const project = (await service.loadWorkbench()).state.projects[0]!; + return { api, project, service }; + }; + const bothEdited = (project: Project): Project => ({ + ...project, + name: 'Renamed', + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }); + + it('sends only the workspace on the next save when the content already landed', async () => { + const { api, project, service } = await open(toSplitRecord(createDraftProject([]))); + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench(stateWith([bothEdited(project)])); + + await service.saveWorkbench(stateWith([bothEdited(project)])); + + expect(api.updateProject).toHaveBeenCalledOnce(); + expect(api.saveProjectWorkspace).toHaveBeenCalledTimes(2); + expect(api.records.get(project.id)).toMatchObject({ revision: 2, workspace: { revision: 2 } }); + }); + + it('sends nothing of the workspace while the content it follows has not landed', async () => { + const { api, project, service } = await open(toSplitRecord(createDraftProject([]))); + vi.mocked(api.updateProject).mockRejectedValueOnce(new TypeError('Failed to fetch')); + + const saved = await service.saveWorkbench(stateWith([bothEdited(project)])); + + expect(saved.hasPendingChanges).toBe(true); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + }); + + it('creates a new project with its content and the caller’s workspace in one request', async () => { + const owner = captureAccountScope(); + const api = createApi(); + const project = createDraftProject([]); + const service = createService(owner, api); + const halves = splitProjectDocument(serializeProjectDocumentV3(project)); + + const saved = await service.saveWorkbench(stateWith([project])); + + // One request stored both halves: the server never held the content alone. + expect(api.createProject).toHaveBeenCalledOnce(); + expect(vi.mocked(api.createProject).mock.calls[0]![0]).toMatchObject({ + data: { documentSchemaVersion: 4 }, + minimum_document_schema_version: 4, + project_id: project.id, + workspace: halves.workspace, + }); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(api.updateProject).not.toHaveBeenCalled(); + expect(saved.hasPendingChanges).toBe(false); + expect(saved.projectBoardAssignments).toEqual([{ boardId: `board-${project.id}`, projectId: project.id }]); + expect(api.records.get(project.id)).toMatchObject({ revision: 1, workspace: { revision: 1 } }); + + // The board the server minted lands in the workspace, whose save builds on the revision the create answered. + const onBoard = withAuthoritativeProjectBoard(project, `board-${project.id}`); + await service.saveWorkbench(stateWith([onBoard])); + expect(api.updateProject).not.toHaveBeenCalled(); + expect(vi.mocked(api.saveProjectWorkspace).mock.calls.map(([, request]) => request.expected_revision)).toEqual([1]); + expect(api.records.get(project.id)).toMatchObject({ revision: 1, workspace: { revision: 2 } }); + }); +}); + +describe('saving a conflicted draft as a new project', () => { + it('creates the copy’s content and this tab’s workspace in one request', async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const source = createDraftProject([]); + api.records.set(source.id, toSplitRecord(source, 2)); + const local = { + ...source, + name: 'local edit', + settings: { ...source.settings, useCpuNoise: !source.settings.useCpuNoise }, + }; + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify(serializeProjectDocumentV3(local)), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: source.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: source.id, + openProjectIds: [source.id], + }); + const service = createService(captureAccountScope(), api, draftStore); + const loaded = await service.loadWorkbench(); + expect(loaded.conflicts).toMatchObject([{ kind: 'revision' }]); + + const copy = await service.resolveConflictSaveAsNew(loaded.state.projects[0]!); + + expect(api.createProject).toHaveBeenCalledOnce(); + expect(vi.mocked(api.createProject).mock.calls[0]![0]).toMatchObject({ + data: { documentSchemaVersion: 4 }, + minimum_document_schema_version: 4, + workspace: { documentSchemaVersion: 4, settings: local.settings }, + }); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(api.records.get(copy.targetProjectId)?.workspace).toMatchObject({ + data: { settings: local.settings }, + revision: 1, + }); + expect(copy.project.settings).toEqual(local.settings); + }); +}); + +describe('recovering a draft against a split project', () => { + const recover = async ( + record: ProjectWithWorkspaceDTO, + draft: { base: number; workspaceBase?: number; project: Project } + ) => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + api.records.set(record.project_id, record); + await draftStore.stage({ + baseRevision: draft.base, + ...(draft.workspaceBase === undefined ? {} : { baseWorkspaceRevision: draft.workspaceBase }), + documentJson: JSON.stringify(serializeProjectDocumentV3(draft.project)), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: record.project_id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api, draftStore); + return { api, loaded: await service.loadWorkbench(), service }; + }; + + it('needs no base for a workspace the server already holds as the draft’s', async () => { + // As a page writes a draft: from the project it loaded from the server. + const loaded = deserializeProjectRecord(toSplitRecord(createDraftProject([]))); + if (loaded.status !== 'loaded') { + throw new Error('Expected the project to load.'); + } + const project = loaded.project; + const settled = { ...project, settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise } }; + // The page saved its workspace (revision 2) and was gone before it heard back; a rename after it never left. + const recovered = await recover(toSplitRecord(settled, 1, 2), { + base: 1, + project: { ...settled, name: 'Renamed after' }, + workspaceBase: 1, + }); + await recovered.service.saveWorkbench(recovered.loaded.state); + + expect(recovered.loaded.conflicts).toEqual([]); + expect(recovered.api.records.get(project.id)).toMatchObject({ + name: 'Renamed after', + revision: 2, + workspace: { revision: 2 }, + }); + }); + + it('replays a draft from before the split onto a project split meanwhile, with no workspace yet', async () => { + const project = createDraftProject([]); + const edited = { ...project, settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise } }; + // Its content landed as the split (revision 2); the page went before saving the workspace. Its draft is a + // record from before the split: no workspace base at all. + const record = { ...toSplitRecord(project, 2), workspace: null, workspace_updated_at: null }; + + const { api, loaded, service } = await recover(record, { base: 1, project: edited }); + await service.saveWorkbench(loaded.state); + + expect(loaded.conflicts).toEqual([]); + expect(api.records.get(project.id)?.workspace).toMatchObject({ data: { settings: edited.settings }, revision: 1 }); + }); + + it.each([ + [ + 'canvas', + { code: 'canvas_schema_unsupported', max_canvas_schema_version: 4, minimum_canvas_schema_version: 5 }, + { kind: 'canvas', minimumCanvasSchemaVersion: 5 }, + ], + [ + 'document', + { code: 'document_schema_unsupported', max_document_schema_version: 4, minimum_document_schema_version: 5 }, + { documentSchemaVersion: 5, kind: 'document' }, + ], + ])('keeps a draft refused by a newer %s schema on open, and saves nothing', async (_floor, detail, refusal) => { + const project = createDraftProject([]); + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + api.records.set(project.id, toSplitRecord(project)); + await draftStore.stage({ + baseRevision: 1, + documentJson: JSON.stringify(serializeProjectDocumentV3({ ...project, name: 'local edit' })), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + const service = createService(captureAccountScope(), api, draftStore); + await service.loadWorkbench(); + vi.mocked(api.getProject).mockRejectedValueOnce(new ApiError(JSON.stringify({ detail }), 412)); + + const opened = await service.hydrateProjectFromServer(project.id); + + expect(opened).toMatchObject({ project: { name: 'local edit' }, status: 'loaded' }); + await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ + draft: { refusal, state: 'schema-refused' }, + }); + }); + + it('refuses a listed project a newer document schema wrote without reading it', async () => { + const project = createDraftProject([]); + const api = createApi(); + api.records.set(project.id, { ...toSplitRecord(project), minimum_document_schema_version: 5 }); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + + const loaded = await createService(captureAccountScope(), api).loadWorkbench(); + + expect(api.getProject).not.toHaveBeenCalled(); + expect(loaded.refusedProjects).toMatchObject([ + { projectId: project.id, refusal: { status: 'unsupported-version', version: 5 }, source: 'project-document' }, + ]); + }); +}); + +describe('resolving a workspace conflict with the saved workspace', () => { + const conflicted = async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const project = createDraftProject([]); + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify( + serializeProjectDocumentV3({ + ...project, + name: 'Edited here', + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }) + ), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + const elsewhere = { ...project, promptHistory: [{ negativePrompt: null, positivePrompt: 'from another tab' }] }; + api.records.set(project.id, toSplitRecord(elsewhere, 1, 2)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(captureAccountScope(), api, draftStore); + const loaded = await service.loadWorkbench(); + expect(loaded.conflicts).toMatchObject([{ half: 'workspace' }]); + return { api, draftStore, elsewhere, project, service }; + }; + + it('reports content another tab saved meanwhile as a conflict, and overwrites nothing', async () => { + const { api, draftStore, elsewhere, project, service } = await conflicted(); + // Another tab saves the project's content after the conflict was raised. + api.records.set(project.id, toSplitRecord({ ...elsewhere, name: 'Other tab content' }, 2, 2)); + + await expect(service.resolveConflictUseServer(project.id)).rejects.toThrow('changed elsewhere as well'); + service.abortProjectResolution(project.id); + await service.saveWorkbench(stateWith([{ ...project, name: 'Edited here' }])); + + expect(api.records.get(project.id)).toMatchObject({ name: 'Other tab content', revision: 2 }); + await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ + draft: { conflict: { kind: 'revision', serverRevision: 2 }, state: 'conflict' }, + }); + }); + + it('builds later edits on the saved workspace, so a page gone before saving them replays them', async () => { + const { api, draftStore, project, service } = await conflicted(); + const resolved = await service.resolveConflictUseServer(project.id); + service.acknowledgeProjectResolution(project.id); + if (resolved.status !== 'loaded') { + throw new Error('Expected the resolution to load.'); + } + // A further workspace edit reaches only the draft before the page goes away. + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench( + stateWith([{ ...resolved.project, promptHistory: [{ negativePrompt: null, positivePrompt: 'after' }] }]) + ); + + const reloaded = await createService(captureAccountScope(), api, draftStore).loadWorkbench(); + + expect(reloaded.conflicts).toEqual([]); + expect(reloaded.state.projects[0]).toMatchObject({ + name: 'Edited here', + promptHistory: [{ positivePrompt: 'after' }], + }); + }); + + it('keeps this tab’s project edits when the server holds no workspace of its own', async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const project = createDraftProject([]); + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify( + serializeProjectDocumentV3({ + ...project, + name: 'Edited here', + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }) + ), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + api.records.set(project.id, { ...toSplitRecord(project), workspace: null, workspace_updated_at: null }); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(captureAccountScope(), api, draftStore); + expect((await service.loadWorkbench()).conflicts).toMatchObject([{ half: 'workspace' }]); + + const resolved = await service.resolveConflictUseServer(project.id); + service.acknowledgeProjectResolution(project.id); + if (resolved.status !== 'loaded') { + throw new Error('Expected the resolution to load.'); + } + await service.saveWorkbench(stateWith([resolved.project])); + + expect(resolved.project).toMatchObject({ name: 'Edited here', settings: project.settings }); + expect(api.records.get(project.id)).toMatchObject({ name: 'Edited here', revision: 2, workspace: { revision: 1 } }); + }); + + it('keeps the conflict when the merge cannot be staged', async () => { + const { api, draftStore, project, service } = await conflicted(); + vi.spyOn(draftStore, 'stage').mockResolvedValueOnce({ kind: 'fenced' }); + + await expect(service.resolveConflictUseServer(project.id)).rejects.toThrow(); + service.abortProjectResolution(project.id); + const saved = await service.saveWorkbench(stateWith([{ ...project, name: 'Edited here' }])); + + expect(saved.conflicts).toMatchObject([{ half: 'workspace' }]); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + }); +}); + +describe('answers lost to the network', () => { + it.each([ + [ + 'content', + 'updateProject', + (project: Project, edit: string): Project => ({ ...project, name: edit }), + { name: 'third', revision: 3 }, + ], + [ + 'workspace', + 'saveProjectWorkspace', + (project: Project, edit: string): Project => ({ + ...project, + promptHistory: [{ negativePrompt: null, positivePrompt: edit }], + }), + { revision: 1, workspace: { data: { promptHistory: [{ positivePrompt: 'third' }] }, revision: 3 } }, + ], + ] as const)( + 'knows an earlier %s save of its own when the edits after it are saved', + async (_half, method, edit, expected) => { + const api = createApi(); + const project = createDraftProject([]); + api.records.set(project.id, toSplitRecord(project)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(captureAccountScope(), api); + const loaded = (await service.loadWorkbench()).state.projects[0]!; + // The first save lands but its answer is lost; the next, of a further edit, is lost before it lands. + const save = vi.mocked(api[method]).getMockImplementation()! as (...args: unknown[]) => Promise; + vi.mocked(api[method]) + .mockImplementationOnce(async (...args: unknown[]) => { + await save(...args); + throw new TypeError('Failed to fetch'); + }) + .mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench(stateWith([edit(loaded, 'first')])); + await service.saveWorkbench(stateWith([edit(loaded, 'second')])); + + const saved = await service.saveWorkbench(stateWith([edit(loaded, 'third')])); + + expect(saved.conflicts).toEqual([]); + expect(api.records.get(project.id)).toMatchObject(expected); + } + ); +}); + +describe('a project from before the split whose workflow repeats an item id', () => { + const withRepeatedNode = (base: Project): Project => { + const [entry, ...rest] = base.workflows.entries; + const node = { data: { label: '', notes: '' }, id: 'node-1', position: { x: 0, y: 0 }, type: 'notes' }; + return { + ...base, + workflows: { + ...base.workflows, + entries: [{ ...entry!, document: { ...entry!.document, nodes: [node, node] } }, ...rest], + }, + } as unknown as Project; + }; + const open = async (record: ProjectWithWorkspaceDTO) => { + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api); + return { api, project: (await service.loadWorkbench()).state.projects[0]!, service }; + }; + + it('keeps saving whole rather than writing content no load would accept', async () => { + const project = withRepeatedNode(createDraftProject([])); + const { api, project: loaded, service } = await open(toRecord(project)); + // Nothing to split, so nothing to send until it changes. + await service.saveWorkbench(stateWith([loaded])); + expect(api.updateProject).not.toHaveBeenCalled(); + + const saved = await service.saveWorkbench(stateWith([{ ...loaded, name: 'Still whole' }])); + + expect(saved.error).toBeNull(); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(api.records.get(project.id)).toMatchObject({ + data: { documentSchemaVersion: 3 }, + minimum_document_schema_version: 3, + name: 'Still whole', + revision: 2, + }); + }); + + it('is created whole, as an import or a recovered draft of one brings it back', async () => { + const api = createApi(); + const project = withRepeatedNode(createDraftProject([])); + const service = createService(captureAccountScope(), api); + + await service.saveWorkbench(stateWith([project])); + const saved = await service.saveWorkbench(stateWith([{ ...project, name: 'Saved again' }])); + + expect(saved.error).toBeNull(); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(vi.mocked(api.createProject).mock.calls[0]![0]).toMatchObject({ + data: { documentSchemaVersion: 3 }, + minimum_document_schema_version: 3, + }); + expect(vi.mocked(api.createProject).mock.calls[0]![0]).not.toHaveProperty('workspace'); + expect(api.records.get(project.id)).toMatchObject({ name: 'Saved again', revision: 2, workspace: null }); + }); + + it('refuses to save a split project once a workflow repeats an id, sending nothing', async () => { + const { api, project, service } = await open(toSplitRecord(createDraftProject([]))); + + const saved = await service.saveWorkbench(stateWith([withRepeatedNode({ ...project, name: 'Repeats' })])); + + expect(saved.error).toMatch(/repeats an item id/); + expect(saved.hasPendingChanges).toBe(true); + expect(api.updateProject).not.toHaveBeenCalled(); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts index 6c8501074f4..33f03298904 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts @@ -24,9 +24,14 @@ import type { ProjectRecordDTO, ProjectSummaryDTO, ProjectUpdateRequest, + ProjectWithWorkspaceDTO, + ProjectWorkspaceDTO, + ProjectWorkspaceSaveRequest, } from './api'; import type { ProjectDraft, + ProjectDraftBases, + ProjectDraftConflict, ProjectDraftCopyReservation, ProjectDraftRetargetCursor, ProjectDraftRetargetHandoff, @@ -47,15 +52,18 @@ import { deleteProject, getProject, getProjectCanvasSchemaCompatibilityRefusal, + getProjectDocumentSchemaRefusal, getProjectWriteSizeRefusal, isProjectConflictError, isProjectNotFoundError, listProjects, ProjectCreateAbsentError, + saveProjectWorkspace, setClientStateValue, updateProject, } from './api'; import { recordProjectCover } from './covers'; +import { PROJECT_DOCUMENT_SCHEMA_VERSION, PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; import { getUtf8ByteSize, getCopySourceProjectName, @@ -65,6 +73,7 @@ import { toConflictProjectDraft, toDirtyProjectDraft, toSchemaRefusedProjectDraft, + withBases, } from './draftStore'; import { createEditorSessionLivenessCheck, getEditorSession, isEditorSessionLive } from './editorSession'; import { createDeterministicProjectId } from './ids'; @@ -72,13 +81,15 @@ import { createAccountOwnedProjectDraftStore } from './indexedDbDraftStore'; import { seedProjectLibrary, upsertProjectSummary } from './library'; import { selectCoverImageName } from './projectAssets'; import { + applyAuthoritativeProjectBoard, migrateProjectDocument, PROJECT_DOCUMENT_MAX_BYTES, - PROJECT_DOCUMENT_SCHEMA_VERSION, serializeProjectDocumentV3Json, + type ProjectDocumentV3, } from './projectDocument'; import { assertProjectFlushed, ProjectFlushError } from './projectFlush'; -import { deserializeProjectDocument, deserializeProjectRecord } from './projectHydration'; +import { findRepeatedWorkflowItem, splitProjectDocument, toProjectCreation } from './projectHalves'; +import { deserializeProjectDocument, deserializeProjectRecord, isSplitProjectRecord } from './projectHydration'; import { acquireProjectMutationLock } from './projectLifecycleLocks'; import { fetchSessionBlobStrict, serializeSessionBlob, SESSION_STATE_KEY } from './session'; import { getOpenProject, reportProjectSync, reportProjectSyncEntry, resolveProjectSyncConflict } from './syncStore'; @@ -86,6 +97,8 @@ import { deleteWorkbenchDatabase } from './workbenchDatabase'; export interface ProjectConflictInfo { detectedAt: string; + /** For a revision conflict: whether the caller's workspace diverged rather than the project content. */ + half?: 'workspace'; kind: 'deleted' | 'revision'; projectId: string; serverRevision?: number; @@ -120,22 +133,37 @@ export interface SaveDraftAsNewInput { sourceProjectId: string; } -export type SaveDraftAsNew = (input: SaveDraftAsNewInput) => Promise; +/** Creates the copy, content and workspace in one request; the record carries the workspace it stored. */ +export type SaveDraftAsNew = (input: SaveDraftAsNewInput) => Promise; + +/** How many unacknowledged bodies of each half a page remembers sending: a few retries' worth, each up to 32 MiB. */ +const SENT_HALVES_KEPT = 4; /** How long a failed upload-provenance creation answers later uploads before another attempt is made. */ const PROJECT_ENSURE_FAILURE_REUSE_MS = 5_000; -const saveDraftAsNew: SaveDraftAsNew = (input) => { - return createProjectSettled( - { - data: input.document, - minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, - name: input.name, - project_id: input.copyProjectId, - }, - input.owner - ); -}; +/** + * Save-as-copy through the persistence API: the copy's content and the caller's workspace in one create, so the copy + * never exists without the workspace (a page gone between two requests would lose it, as sign-out deletes its drafts). + */ +const createSaveDraftAsNew = + (api: DurableProjectPersistenceApi): SaveDraftAsNew => + async (input) => { + const creation = toProjectCreation(input.document as unknown as ProjectDocumentV3); + const record = await api.createProject( + { + data: creation.data as unknown as Record, + minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + minimum_document_schema_version: creation.documentSchemaVersion, + name: input.name, + project_id: input.copyProjectId, + ...(creation.workspace ? { workspace: creation.workspace as unknown as Record } : {}), + }, + input.owner + ); + assertAccountScopeCurrent(input.owner); + return record; + }; const mapConcurrent = async ( items: readonly T[], @@ -241,11 +269,17 @@ export interface DurableHydratedWorkbenchSnapshot extends HydratedWorkbenchSnaps } export interface DurableProjectPersistenceApi { - createProject(request: ProjectCreateRequest, owner: AccountScope): Promise; + /** Answers with the workspace the create stored, null when the request carried none (a document created whole). */ + createProject(request: ProjectCreateRequest, owner: AccountScope): Promise; deleteProject(projectId: string, signal?: AbortSignal, boards?: DeleteProjectBoards): Promise; deleteSession(signal?: AbortSignal): Promise; - getProject(projectId: string, signal?: AbortSignal): Promise; + getProject(projectId: string, signal?: AbortSignal): Promise; listProjects(signal?: AbortSignal): Promise; + saveProjectWorkspace( + projectId: string, + request: ProjectWorkspaceSaveRequest, + signal?: AbortSignal + ): Promise; loadSession(signal?: AbortSignal): Promise; saveSession( state: WorkbenchState, @@ -253,7 +287,11 @@ export interface DurableProjectPersistenceApi { draftEditorSessionIds: Record, signal?: AbortSignal ): Promise; - updateProject(projectId: string, request: ProjectUpdateRequest, signal?: AbortSignal): Promise; + updateProject( + projectId: string, + request: ProjectUpdateRequest, + signal?: AbortSignal + ): Promise; } const productionApi: DurableProjectPersistenceApi = { @@ -263,6 +301,7 @@ const productionApi: DurableProjectPersistenceApi = { getProject, listProjects, loadSession: fetchSessionBlobStrict, + saveProjectWorkspace, saveSession: (state, editorSessionId, draftEditorSessionIds, signal) => setClientStateValue(SESSION_STATE_KEY, serializeSessionBlob(state, editorSessionId, draftEditorSessionIds), signal), updateProject, @@ -287,10 +326,25 @@ interface DurableSyncedPersistenceDependencies { writerToken?: string; } +/** + * What the server holds of a project, as this page last learned it. The content and the caller's workspace are + * acknowledged one after the other, so each has its own pushed copy and revision; `pushedDoc` is the whole document + * as last acknowledged with both. + */ interface SyncEntry { minimumCanvasSchemaVersion: number; + /** + * The whole document the server holds, or '' while that is not known to be any one document (one half acknowledged + * without the other). It is the cheap test for "nothing to save", so it must never name a document the server lacks. + */ pushedDoc: string; + /** The content's revision. */ revision: number; + /** The content the server holds; null while it holds the project whole (saved before the split). */ + pushedContent: string | null; + /** The caller's workspace the server holds; null while it holds none. */ + pushedWorkspace: string | null; + workspaceRevision: number | null; } interface LoadedDraft { @@ -316,36 +370,94 @@ const toSnapshot = (state: WorkbenchState, savedAt: string): HydratedWorkbenchSn version: 1, }); -const recordDocumentJson = (record: ProjectRecordDTO): string | null => { - const loaded = deserializeProjectRecord(record); - - return loaded.status === 'loaded' ? serializeProjectDocumentV3Json(loaded.project).documentJson : null; -}; - -const toProjectSummary = (record: ProjectRecordDTO): ProjectSummaryDTO => ({ +const toProjectSummary = (record: ProjectWithWorkspaceDTO): ProjectSummaryDTO => ({ board_id: record.board_id, created_at: record.created_at, minimum_canvas_schema_version: record.minimum_canvas_schema_version, + minimum_document_schema_version: record.minimum_document_schema_version, name: record.name, project_id: record.project_id, revision: record.revision, updated_at: record.updated_at, + workspace_updated_at: record.workspace?.updated_at ?? record.workspace_updated_at, }); -const recordRequiresDocumentUpgrade = (record: ProjectRecordDTO): boolean => - record.data.documentSchemaVersion !== PROJECT_DOCUMENT_SCHEMA_VERSION; +/** A project the server holds whole is split by its next save, whether or not anything changed. */ +/** A project the server holds whole is split by its next save, unless it must stay whole (`toProjectCreation`). */ +const recordRequiresDocumentUpgrade = (record: ProjectRecordDTO, project: Project): boolean => + !isSplitProjectRecord(record) && findRepeatedWorkflowItem(project.workflows.entries) === null; + +/** What the server holds of a read, which loaded as `project`. */ +const toSyncEntry = (record: ProjectWithWorkspaceDTO, project: Project): SyncEntry => { + const { workspace } = record; + const { document, documentJson } = serializeProjectDocumentV3Json(project); + const halves = splitProjectDocument(document); + const isSplit = isSplitProjectRecord(record); + return { + minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, + pushedContent: isSplit ? halves.contentJson : null, + pushedDoc: documentJson, + pushedWorkspace: isSplit && workspace ? halves.workspaceJson : null, + revision: record.revision, + workspaceRevision: workspace?.revision ?? null, + }; +}; const toCanvasSchemaRefusal = (refusal: { maxCanvasSchemaVersion: number; minimumCanvasSchemaVersion: number; }): ProjectSchemaRefusal => ({ ...refusal, kind: 'canvas' }); -const toDocumentSchemaRefusal = (documentSchemaVersion: number): ProjectSchemaRefusal => ({ +const toDocumentSchemaRefusal = ( + documentSchemaVersion: number +): Extract => ({ documentSchemaVersion, kind: 'document', - maxDocumentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, + maxDocumentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, }); +/** The refusal a 412 carries, of either floor: a newer Invoke wrote the project. */ +const toSchemaRefusal = (error: unknown): ProjectSchemaRefusal | null => { + const canvas = getProjectCanvasSchemaCompatibilityRefusal(error); + if (canvas) { + return toCanvasSchemaRefusal(canvas); + } + const documentRefusal = getProjectDocumentSchemaRefusal(error); + return documentRefusal ? toDocumentSchemaRefusal(documentRefusal.minimumDocumentSchemaVersion) : null; +}; + +/** A project a newer Invoke wrote, as the workbench reports it when there is no local copy to open instead. */ +const toRefusedProject = ( + projectId: string, + projectName: string, + refusal: Extract +): RefusedWorkbenchProject => + refusal.kind === 'document' + ? { + projectId, + projectName, + raw: null, + refusal: { + raw: null, + scope: 'project-document', + status: 'unsupported-version', + version: refusal.documentSchemaVersion, + }, + source: 'project-document', + } + : { + projectId, + projectName, + raw: null, + refusal: { + raw: null, + scope: 'document', + status: 'unsupported-version', + version: refusal.minimumCanvasSchemaVersion, + }, + source: 'canvas', + }; + const loadDraftProject = (draft: ProjectDraft): Project | null => { try { const raw = JSON.parse(draft.documentJson) as unknown; @@ -396,7 +508,7 @@ export class ProjectDraftWriteRejectedError extends Error { export interface DurableSyncedWorkbenchPersistence { acknowledgeProjectResolution(projectId: string): void; abortProjectResolution(projectId: string): void; - adoptProjectRecord(record: ProjectRecordDTO): ProjectLoadResult; + adoptProjectRecord(record: ProjectWithWorkspaceDTO): ProjectLoadResult; clearWorkbench(): Promise; close(): void; deleteRecoverableDraft( @@ -516,7 +628,7 @@ export const createDurableSyncedWorkbenchPersistence = ( } }; const writerToken = dependencies.writerToken ?? createUuid(); - const saveAsNew = dependencies.saveDraftAsNew ?? saveDraftAsNew; + const saveAsNew = dependencies.saveDraftAsNew ?? createSaveDraftAsNew(api); const deleteDatabase = dependencies.deleteDatabase ?? deleteWorkbenchDatabase; const databaseDeleteTimeoutMs = dependencies.databaseDeleteTimeoutMs ?? 5_000; const syncEntries = new Map(); @@ -540,6 +652,11 @@ export const createDurableSyncedWorkbenchPersistence = ( const serverDeletedProjectIds = new Set(); const pendingProjectIds = new Set(); const projectsRequiringDocumentUpgrade = new Set(); + /** + * The bodies this page sent of each half of a project since both were last acknowledged, newest last and at most + * `SENT_HALVES_KEPT`: any of them on the server is this page's own write, its answer lost. + */ + const sentHalves = new Map(); const pendingBoardAssignments = new Map(); const retargetedProjects = new Map< string, @@ -931,7 +1048,9 @@ export const createDurableSyncedWorkbenchPersistence = ( const serialized = serializeProjectDocumentV3Json(project); const entry = syncEntries.get(project.id); const existingDraft = volatileDrafts.get(project.id); - const needsDocumentUpgrade = projectsRequiringDocumentUpgrade.has(project.id); + // A conflicted or refused project pushes nothing until resolved, so its upgrade waits too. + const needsDocumentUpgrade = + projectsRequiringDocumentUpgrade.has(project.id) && !conflicts.has(project.id) && !schemaRefusals.has(project.id); const needsStage = needsDocumentUpgrade || (!entry && existingDraft?.documentJson !== serialized.documentJson) || @@ -965,6 +1084,7 @@ export const createDurableSyncedWorkbenchPersistence = ( baseMinimumCanvasSchemaVersion: entry?.minimumCanvasSchemaVersion ?? getProjectCanvasSchemaRequirement(serialized.document), baseRevision: entry?.revision ?? null, + baseWorkspaceRevision: entry?.workspaceRevision ?? null, documentJson: serialized.documentJson, documentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, editorSessionId: getDraftEditorSessionId(project.id, session.id), @@ -1014,7 +1134,11 @@ export const createDurableSyncedWorkbenchPersistence = ( ? toConflictProjectDraft( dirty, conflict.kind === 'revision' - ? { kind: 'revision', serverRevision: conflict.serverRevision! } + ? { + ...(conflict.half ? { half: conflict.half } : {}), + kind: 'revision', + serverRevision: conflict.serverRevision!, + } : { kind: 'deleted' } ) : refusal @@ -1036,29 +1160,28 @@ export const createDurableSyncedWorkbenchPersistence = ( return { editorSessionId: draftInput.editorSessionId, generation, serialized, store }; }; + /** Both halves are on the server as `acknowledged` says: the generation sent is acknowledged. */ const settleAck = async ( project: Project, sentGeneration: number, - record: ProjectRecordDTO, + acknowledged: Omit, + document: Record, documentJson: string, store: ProjectDraftStore, editorSessionId: string ): Promise => { - syncEntries.set(project.id, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: documentJson, - revision: record.revision, - }); + syncEntries.set(project.id, { ...acknowledged, pushedDoc: documentJson }); + sentHalves.delete(project.id); upsertProjectSummary( { id: project.id, - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, + minimumCanvasSchemaVersion: acknowledged.minimumCanvasSchemaVersion, name: project.name, - revision: record.revision, + revision: acknowledged.revision, }, owner ); - recordProjectCover(project.id, selectCoverImageName(record.data), owner); + recordProjectCover(project.id, selectCoverImageName(document), owner); conflicts.delete(project.id); schemaRefusals.delete(project.id); projectsRequiringDocumentUpgrade.delete(project.id); @@ -1069,8 +1192,9 @@ export const createDurableSyncedWorkbenchPersistence = ( editorSessionId, writerToken, sentGeneration, - record.revision, - record.minimum_canvas_schema_version + acknowledged.revision, + acknowledged.minimumCanvasSchemaVersion, + acknowledged.workspaceRevision ); const journaled = journaledDocuments.get(project.id); if (journaled && journaled.generation <= sentGeneration) { @@ -1078,13 +1202,15 @@ export const createDurableSyncedWorkbenchPersistence = ( } else if ( journaled && journaled.editorSessionId === editorSessionId && - journaled.baseRevision !== record.revision + (journaled.baseRevision !== acknowledged.revision || + (journaled.baseWorkspaceRevision ?? null) !== acknowledged.workspaceRevision) ) { // A newer journaled document builds on the acknowledged one: rebase it, as a newer staged draft is rebased. const rebased = { ...journaled, - baseMinimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - baseRevision: record.revision, + baseMinimumCanvasSchemaVersion: acknowledged.minimumCanvasSchemaVersion, + baseRevision: acknowledged.revision, + baseWorkspaceRevision: acknowledged.workspaceRevision, }; const write = store.journalBeforeUnload({ entries: [rebased], retired: [] }); if (write.kind === 'started' && (await write.written) && journaledDocuments.get(project.id) === journaled) { @@ -1103,7 +1229,13 @@ export const createDurableSyncedWorkbenchPersistence = ( const volatile = volatileDrafts.get(project.id); if (volatile) { if (volatile.generation > sentGeneration) { - volatileDrafts.set(project.id, toDirtyProjectDraft(volatile, { baseRevision: record.revision })); + volatileDrafts.set( + project.id, + toDirtyProjectDraft(volatile, { + baseRevision: acknowledged.revision, + baseWorkspaceRevision: acknowledged.workspaceRevision, + }) + ); pendingProjectIds.add(project.id); hasPending = true; } else { @@ -1112,10 +1244,41 @@ export const createDurableSyncedWorkbenchPersistence = ( } }; + /** + * A recovered draft against what the server holds: null when the draft builds on it, else the conflict. Each half is + * judged apart: a half equal to the server's needs no base (a page can save the content and be gone before the + * workspace), and a changed half conflicts only if the server moved it past the draft's base. + */ + const findDraftDivergence = ( + draft: ProjectDraft, + serverProject: Project, + record: ProjectWithWorkspaceDTO + ): ProjectDraftConflict | null => { + // Built on what the server holds of both halves: nothing to compare, which on a large document is costly. + if ( + draft.baseRevision === record.revision && + (draft.baseWorkspaceRevision ?? null) === (record.workspace?.revision ?? null) + ) { + return null; + } + const ours = splitProjectDocument(JSON.parse(draft.documentJson) as ProjectDocumentV3); + const theirs = splitProjectDocument(serializeProjectDocumentV3Json(serverProject).document); + if (ours.contentJson !== theirs.contentJson && draft.baseRevision !== record.revision) { + return { kind: 'revision', serverRevision: record.revision }; + } + if ( + ours.workspaceJson !== theirs.workspaceJson && + (draft.baseWorkspaceRevision ?? null) !== (record.workspace?.revision ?? null) + ) { + return { half: 'workspace', kind: 'revision', serverRevision: record.revision }; + } + return null; + }; + const markConflict = async ( projectId: string, generation: number, - conflict: { kind: 'deleted' } | { kind: 'revision'; serverRevision: number }, + conflict: ProjectDraftConflict, store: ProjectDraftStore, editorSessionId: string ): Promise => { @@ -1124,6 +1287,7 @@ export const createDurableSyncedWorkbenchPersistence = ( kind: conflict.kind, projectId, ...(conflict.kind === 'revision' ? { serverRevision: conflict.serverRevision } : {}), + ...(conflict.kind === 'revision' && conflict.half ? { half: conflict.half } : {}), }; conflicts.set(projectId, info); pendingProjectIds.add(projectId); @@ -1187,40 +1351,97 @@ export const createDurableSyncedWorkbenchPersistence = ( return { documentJson, kind: 'conflicted' }; } if (entry?.pushedDoc === documentJson && !projectsRequiringDocumentUpgrade.has(project.id)) { - await settleAck( - project, - generation, - { - board_id: '', - created_at: '', - data: document, - minimum_canvas_schema_version: entry.minimumCanvasSchemaVersion, - name: project.name, - project_id: project.id, - revision: entry.revision, - updated_at: '', - }, - documentJson, - store, - editorSessionId - ); + await settleAck(project, generation, entry, document, documentJson, store, editorSessionId); return { documentJson, kind: 'acknowledged' }; } - try { - if (!entry) { + const halves = splitProjectDocument(document); + // Split content that repeats an item id would be refused by every later load, so such a project, which only a + // build before the split could have written, is created whole and keeps saving whole (see `toProjectCreation`). + const keepsWhole = findRepeatedWorkflowItem(halves.content.workflows.entries) !== null; + if (keepsWhole && entry && entry.pushedContent !== null) { + hasPending = true; + throw new Error( + 'A workflow in this project now repeats an item id, so its changes cannot be saved. Undo the last workflow change to keep saving.' + ); + } + /** + * The whole document the server holds once `entry` is, only when that is this one: with one half acknowledged + * and not the other, it holds neither this document nor the last one, and the shortcut that trusts `pushedDoc` + * must not take either for it. + */ + const acknowledgedDocument = (entry: Omit): string => + entry.pushedContent === halves.contentJson && entry.pushedWorkspace === halves.workspaceJson ? documentJson : ''; + const settleRefusal = async (refusal: ProjectSchemaRefusal): Promise => { + schemaRefusals.set(project.id, refusal); + hasPending = true; + await store.settleSchemaRefusal(project.id, editorSessionId, writerToken, generation, refusal); + const volatile = volatileDrafts.get(project.id); + if (volatile) { + volatileDrafts.set(project.id, toSchemaRefusedProjectDraft(volatile, refusal)); + } + return { documentJson, kind: 'schema-refused', refusal }; + }; + const conflicted = async (conflict: ProjectDraftConflict): Promise => { + await markConflict(project.id, generation, conflict, store, editorSessionId); + return { documentJson, kind: 'conflicted' }; + }; + + /** Records what the server acknowledged of either half, as soon as it has, so a retry sends only what is owed. */ + const record = (next: Omit): Omit => { + syncEntries.set(project.id, { ...next, pushedDoc: acknowledgedDocument(next) }); + return next; + }; + /** What this page sent of a half, kept until acknowledged: a save that landed with its answer lost is its own. */ + const noteSent = (half: 'content' | 'workspace', json: string): void => { + const sent = sentHalves.get(project.id) ?? { content: [], workspace: [] }; + sentHalves.set(project.id, { + ...sent, + [half]: [...sent[half].filter((body) => body !== json), json].slice(-SENT_HALVES_KEPT), + }); + }; + /** + * The lineage rebased onto the content just acknowledged, before the workspace is sent: should that save fail or + * the page go, recovery builds on the content the server now holds rather than conflicting with its own save. + */ + const rebaseLineage = async (bases: ProjectDraftBases): Promise => { + await store.rebaseDraft(project.id, editorSessionId, writerToken, generation, bases); + const volatile = volatileDrafts.get(project.id); + if (volatile) { + volatileDrafts.set(project.id, withBases(volatile, bases)); + } + const journaled = journaledDocuments.get(project.id); + if (journaled && journaled.editorSessionId === editorSessionId) { + const rebased = { ...journaled, ...bases }; + const write = store.journalBeforeUnload({ entries: [rebased], retired: [] }); + if (write.kind === 'started' && (await write.written) && journaledDocuments.get(project.id) === journaled) { + journaledDocuments.set(project.id, rebased); + } + } + }; + + /** + * A project's first save, and the split of one the server holds whole, are one request each, which stores the + * content and the caller's workspace together: between two requests the server would hold the content alone, and + * a page gone at that moment (sign-out deletes its drafts) would leave the member's workspace lost for good. Once + * split, the halves go one after the other, content first: the server takes one write per account at a time, and + * the content's save is what raises the document floor, so a workspace never sits beside a project an older page + * could still save whole. + */ + const pushHalves = async (base: SyncEntry | undefined): Promise | 'conflicted'> => { + if (keepsWhole && !base) { const created = await api.createProject( { - data: document, + data: document as unknown as Record, minimum_canvas_schema_version: getProjectCanvasSchemaRequirement(document), + minimum_document_schema_version: PROJECT_DOCUMENT_SCHEMA_VERSION, name: project.name, project_id: project.id, }, owner ); assertOwner(); - const serverJson = JSON.stringify(created.data); - if (serverJson !== documentJson) { + if (JSON.stringify(created.data) !== documentJson) { await markConflict( project.id, generation, @@ -1228,145 +1449,272 @@ export const createDurableSyncedWorkbenchPersistence = ( store, editorSessionId ); - return { documentJson, kind: 'conflicted' }; + return 'conflicted'; } - await settleAck(project, generation, created, documentJson, store, editorSessionId); pendingBoardAssignments.set(project.id, { boardId: created.board_id, projectId: project.id }); - return { documentJson, kind: 'acknowledged' }; - } - - const raisedFloor = getRaisedCanvasSchemaFloor(document, entry.minimumCanvasSchemaVersion); - const updated = await api.updateProject( - project.id, - { - data: document, - expected_revision: entry.revision, - ...(raisedFloor === undefined ? {} : { minimum_canvas_schema_version: raisedFloor }), - name: project.name, - }, - owner.signal - ); - assertOwner(); - await settleAck(project, generation, updated, documentJson, store, editorSessionId); - return { documentJson, kind: 'acknowledged' }; - } catch (error) { - assertOwner(); - const refusalError = error instanceof ProjectCreateAbsentError ? error.cause : error; - const sizeRefusal = getProjectWriteSizeRefusal(refusalError); - if (sizeRefusal) { - hasPending = true; - throw new ProjectDocumentTooLargeError(sizeRefusal.actualBytes, sizeRefusal.maxBytes); - } - const schemaRefusal = getProjectCanvasSchemaCompatibilityRefusal(refusalError); - if (schemaRefusal) { - const refusal = toCanvasSchemaRefusal(schemaRefusal); - schemaRefusals.set(project.id, refusal); - hasPending = true; - await store.settleSchemaRefusal(project.id, editorSessionId, writerToken, generation, refusal); - const volatile = volatileDrafts.get(project.id); - if (volatile) { - volatileDrafts.set(project.id, toSchemaRefusedProjectDraft(volatile, refusal)); - } - return { documentJson, kind: 'schema-refused', refusal }; - } - if (error instanceof ProjectCreateAbsentError) { - throw error.cause; + return record({ + minimumCanvasSchemaVersion: created.minimum_canvas_schema_version, + pushedContent: null, + pushedWorkspace: null, + revision: created.revision, + workspaceRevision: null, + }); } - if (isStatus(error, 404)) { - if (deletedProjectIds.has(project.id)) { - return { documentJson, kind: 'superseded' }; - } - await markConflict(project.id, generation, { kind: 'deleted' }, store, editorSessionId); - return { documentJson, kind: 'conflicted' }; + if (keepsWhole && base) { + const updated = await api.updateProject( + project.id, + { + data: document as unknown as Record, + expected_revision: base.revision, + name: project.name, + }, + owner.signal + ); + assertOwner(); + // Never splitting, it needs no upgrade: the shortcut for an unchanged document applies again. + projectsRequiringDocumentUpgrade.delete(project.id); + return record({ + ...base, + minimumCanvasSchemaVersion: updated.minimum_canvas_schema_version, + pushedContent: null, + pushedWorkspace: null, + revision: updated.revision, + }); } - if (isStatus(error, 409)) { - try { - const current = await api.getProject(project.id, owner.signal); - assertOwner(); - const currentJson = recordDocumentJson(current); - if (currentJson === documentJson) { - await settleAck(project, generation, current, documentJson, store, editorSessionId); - return { documentJson, kind: 'acknowledged' }; - } - if (entry && currentJson === entry.pushedDoc) { - syncEntries.set(project.id, { - minimumCanvasSchemaVersion: current.minimum_canvas_schema_version, - pushedDoc: entry.pushedDoc, - revision: current.revision, - }); - const retryRaisedFloor = getRaisedCanvasSchemaFloor(document, current.minimum_canvas_schema_version); - const retried = await api.updateProject( - project.id, - { - data: document, - expected_revision: current.revision, - ...(retryRaisedFloor === undefined ? {} : { minimum_canvas_schema_version: retryRaisedFloor }), - name: project.name, - }, - owner.signal - ); - assertOwner(); - await settleAck(project, generation, retried, documentJson, store, editorSessionId); - return { documentJson, kind: 'acknowledged' }; - } + let acknowledged: Omit; + const workspaceOwed = base?.pushedWorkspace !== halves.workspaceJson; + if (!base) { + noteSent('content', halves.contentJson); + noteSent('workspace', halves.workspaceJson); + const created = await api.createProject( + { + data: halves.content as unknown as Record, + minimum_canvas_schema_version: getProjectCanvasSchemaRequirement(document), + minimum_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + name: project.name, + project_id: project.id, + workspace: halves.workspace as unknown as Record, + }, + owner + ); + assertOwner(); + if (JSON.stringify(created.data) !== halves.contentJson) { await markConflict( project.id, generation, - { kind: 'revision', serverRevision: current.revision }, + { kind: 'revision', serverRevision: created.revision }, store, editorSessionId ); + return 'conflicted'; + } + pendingBoardAssignments.set(project.id, { boardId: created.board_id, projectId: project.id }); + // The answer names the workspace it stored, which is this save's only when it is what this save sent: a + // create settled by a read of an earlier attempt that committed unanswered carries that attempt's workspace, + // and the content it is judged by may not have changed since. Any other workspace stays owed, and is sent + // next against the revision the answer named; a server that stored none leaves it owed the same way. + const workspaceStored = + created.workspace !== null && JSON.stringify(created.workspace.data) === halves.workspaceJson; + acknowledged = record({ + minimumCanvasSchemaVersion: created.minimum_canvas_schema_version, + pushedContent: halves.contentJson, + pushedWorkspace: workspaceStored ? halves.workspaceJson : null, + revision: created.revision, + workspaceRevision: created.workspace?.revision ?? null, + }); + if (!workspaceStored) { + await rebaseLineage({ + baseMinimumCanvasSchemaVersion: created.minimum_canvas_schema_version, + baseRevision: created.revision, + }); + } + } else if (base.pushedContent === null) { + // The server holds the project whole: the split, in one request. + const raisedFloor = getRaisedCanvasSchemaFloor(document, base.minimumCanvasSchemaVersion); + noteSent('content', halves.contentJson); + noteSent('workspace', halves.workspaceJson); + const updated = await api.updateProject( + project.id, + { + data: halves.content as unknown as Record, + expected_revision: base.revision, + ...(raisedFloor === undefined ? {} : { minimum_canvas_schema_version: raisedFloor }), + minimum_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + name: project.name, + workspace: { + data: halves.workspace as unknown as Record, + expected_revision: base.workspaceRevision, + }, + }, + owner.signal + ); + assertOwner(); + projectsRequiringDocumentUpgrade.delete(project.id); + // The answer names the workspace it stored; a server that stored none leaves it owed, and it is sent next. + acknowledged = record({ + minimumCanvasSchemaVersion: updated.minimum_canvas_schema_version, + pushedContent: halves.contentJson, + pushedWorkspace: updated.workspace ? halves.workspaceJson : null, + revision: updated.revision, + workspaceRevision: updated.workspace?.revision ?? null, + }); + if (!updated.workspace) { + await rebaseLineage({ + baseMinimumCanvasSchemaVersion: updated.minimum_canvas_schema_version, + baseRevision: updated.revision, + }); + } + } else if (projectsRequiringDocumentUpgrade.has(project.id) || base.pushedContent !== halves.contentJson) { + const raisedFloor = getRaisedCanvasSchemaFloor(document, base.minimumCanvasSchemaVersion); + noteSent('content', halves.contentJson); + const updated = await api.updateProject( + project.id, + { + data: halves.content as unknown as Record, + expected_revision: base.revision, + ...(raisedFloor === undefined ? {} : { minimum_canvas_schema_version: raisedFloor }), + // Idempotent on a split project. + minimum_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + name: project.name, + }, + owner.signal + ); + assertOwner(); + projectsRequiringDocumentUpgrade.delete(project.id); + acknowledged = record({ + ...base, + minimumCanvasSchemaVersion: updated.minimum_canvas_schema_version, + pushedContent: halves.contentJson, + revision: updated.revision, + }); + if (workspaceOwed) { + await rebaseLineage({ + baseMinimumCanvasSchemaVersion: updated.minimum_canvas_schema_version, + baseRevision: updated.revision, + }); + } + } else { + acknowledged = base; + } + if (acknowledged.pushedWorkspace !== halves.workspaceJson) { + noteSent('workspace', halves.workspaceJson); + const saved = await api.saveProjectWorkspace( + project.id, + { + data: halves.workspace as unknown as Record, + expected_revision: acknowledged.workspaceRevision, + }, + owner.signal + ); + assertOwner(); + acknowledged = record({ + ...acknowledged, + pushedWorkspace: halves.workspaceJson, + workspaceRevision: saved.revision, + }); + } + return acknowledged; + }; + + /** + * After a revision conflict: what the server holds of each half. A half it holds as ours is acknowledged; a half + * it still holds as this page last saw it was moved by a save of the same bytes, and is retried at its revision; + * any other half diverged. + */ + const reconcile = async (): Promise< + | { kind: 'acknowledged' | 'retry'; entry: Omit } + | { kind: 'diverged'; half?: 'workspace'; serverRevision: number } + > => { + const latest = await api.getProject(project.id, owner.signal); + assertOwner(); + const loaded = deserializeProjectRecord(latest); + if (loaded.status !== 'loaded') { + return { kind: 'diverged', serverRevision: latest.revision }; + } + const server = serializeProjectDocumentV3Json(loaded.project); + const serverHalves = splitProjectDocument(server.document); + const split = isSplitProjectRecord(latest); + const base = syncEntries.get(project.id); + const contentHeld = split && serverHalves.contentJson === halves.contentJson; + const sent = sentHalves.get(project.id); + // Unmoved: still what this page last had acknowledged, or what it sent and never heard back about. + const contentUnmoved = split + ? base?.pushedContent === serverHalves.contentJson || sent?.content.includes(serverHalves.contentJson) === true + : base !== undefined && base.pushedContent === null && base.pushedDoc === server.documentJson; + const workspaceHeld = Boolean(latest.workspace) && serverHalves.workspaceJson === halves.workspaceJson; + const workspaceUnmoved = latest.workspace + ? base?.pushedWorkspace === serverHalves.workspaceJson || + sent?.workspace.includes(serverHalves.workspaceJson) === true + : (base?.workspaceRevision ?? null) === null; + if (!(contentHeld || contentUnmoved)) { + return { kind: 'diverged', serverRevision: latest.revision }; + } + if (!(workspaceHeld || workspaceUnmoved)) { + return { half: 'workspace', kind: 'diverged', serverRevision: latest.revision }; + } + const entry = { + minimumCanvasSchemaVersion: latest.minimum_canvas_schema_version, + pushedContent: split ? serverHalves.contentJson : null, + pushedWorkspace: latest.workspace ? serverHalves.workspaceJson : null, + revision: latest.revision, + workspaceRevision: latest.workspace?.revision ?? null, + }; + return { entry, kind: contentHeld && workspaceHeld ? 'acknowledged' : 'retry' }; + }; + + let base = entry; + // One retry: a second conflict means someone is saving this project now, which is a divergence to resolve. + for (let attempt = 0; ; attempt += 1) { + try { + const acknowledged = await pushHalves(base); + if (acknowledged === 'conflicted') { return { documentJson, kind: 'conflicted' }; + } + await settleAck(project, generation, acknowledged, document, documentJson, store, editorSessionId); + return { documentJson, kind: 'acknowledged' }; + } catch (error) { + assertOwner(); + const refusalError = error instanceof ProjectCreateAbsentError ? error.cause : error; + const sizeRefusal = getProjectWriteSizeRefusal(refusalError); + if (sizeRefusal) { + hasPending = true; + throw new ProjectDocumentTooLargeError(sizeRefusal.actualBytes, sizeRefusal.maxBytes); + } + const schemaRefusal = toSchemaRefusal(refusalError); + if (schemaRefusal) { + return settleRefusal(schemaRefusal); + } + if (error instanceof ProjectCreateAbsentError) { + throw error.cause; + } + if (isStatus(error, 404)) { + if (deletedProjectIds.has(project.id)) { + return { documentJson, kind: 'superseded' }; + } + return conflicted({ kind: 'deleted' }); + } + if (!isStatus(error, 409)) { + if (isDeterministicClientError(error)) { + throw error; + } + hasPending = true; + return { documentJson, kind: 'unsynced' }; + } + let settled: Awaited>; + try { + settled = await reconcile(); } catch (readError) { assertOwner(); const retrySizeRefusal = getProjectWriteSizeRefusal(readError); if (retrySizeRefusal) { throw new ProjectDocumentTooLargeError(retrySizeRefusal.actualBytes, retrySizeRefusal.maxBytes); } - const retrySchemaRefusal = getProjectCanvasSchemaCompatibilityRefusal(readError); - if (retrySchemaRefusal) { - const refusal = toCanvasSchemaRefusal(retrySchemaRefusal); - schemaRefusals.set(project.id, refusal); - await store.settleSchemaRefusal(project.id, editorSessionId, writerToken, generation, refusal); - const volatile = volatileDrafts.get(project.id); - if (volatile) { - volatileDrafts.set(project.id, toSchemaRefusedProjectDraft(volatile, refusal)); - } - return { documentJson, kind: 'schema-refused', refusal }; + const readRefusal = toSchemaRefusal(readError); + if (readRefusal) { + return settleRefusal(readRefusal); } if (isStatus(readError, 404)) { - await markConflict(project.id, generation, { kind: 'deleted' }, store, editorSessionId); - return { documentJson, kind: 'conflicted' }; - } - if (isStatus(readError, 409)) { - let latest: ProjectRecordDTO; - try { - latest = await api.getProject(project.id, owner.signal); - } catch (latestError) { - assertOwner(); - if (isStatus(latestError, 404)) { - await markConflict(project.id, generation, { kind: 'deleted' }, store, editorSessionId); - return { documentJson, kind: 'conflicted' }; - } - if (isDeterministicClientError(latestError)) { - throw latestError; - } - hasPending = true; - return { documentJson, kind: 'unsynced' }; - } - assertOwner(); - if (recordDocumentJson(latest) === documentJson) { - await settleAck(project, generation, latest, documentJson, store, editorSessionId); - return { documentJson, kind: 'acknowledged' }; - } - await markConflict( - project.id, - generation, - { kind: 'revision', serverRevision: latest.revision }, - store, - editorSessionId - ); - return { documentJson, kind: 'conflicted' }; + return conflicted({ kind: 'deleted' }); } if (isDeterministicClientError(readError)) { throw readError; @@ -1374,12 +1722,20 @@ export const createDurableSyncedWorkbenchPersistence = ( hasPending = true; return { documentJson, kind: 'unsynced' }; } + if (settled.kind === 'acknowledged') { + await settleAck(project, generation, settled.entry, document, documentJson, store, editorSessionId); + return { documentJson, kind: 'acknowledged' }; + } + if (settled.kind === 'diverged' || attempt > 0) { + return conflicted({ + ...(settled.kind === 'diverged' && settled.half ? { half: settled.half } : {}), + kind: 'revision', + serverRevision: settled.kind === 'diverged' ? settled.serverRevision : settled.entry.revision, + }); + } + base = { ...settled.entry, pushedDoc: acknowledgedDocument(settled.entry) }; + syncEntries.set(project.id, base); } - if (isDeterministicClientError(error)) { - throw error; - } - hasPending = true; - return { documentJson, kind: 'unsynced' }; } }; @@ -1452,6 +1808,7 @@ export const createDurableSyncedWorkbenchPersistence = ( conflict: { detectedAt: conflict.detectedAt, kind: conflict.kind, + ...(conflict.half ? { half: conflict.half } : {}), ...(conflict.serverRevision === undefined ? {} : { serverRevision: conflict.serverRevision }), }, } @@ -1638,6 +1995,94 @@ export const createDurableSyncedWorkbenchPersistence = ( await settleJournaledDocument(projectId, store); }; + /** + * Resolves a workspace conflict with the server's workspace, keeping this tab's project content: only the workspace + * diverged, and the content builds on what the server holds, so it is still this tab's to save. Content the server + * moved since is a conflict of its own, reported as one rather than overwritten. The draft is rewritten in place, + * never deleted first, so a crash at any point leaves either the conflict or the merge; a failure restores the + * conflict. + */ + const useServerWorkspace = async ( + projectId: string, + record: ProjectWithWorkspaceDTO, + serverProject: Project + ): Promise => { + const { draft, editorSessionId, store } = await requireDraft(projectId); + // This tab's latest: what it last handed a save, which may not be staged yet, or else its draft. + const local = lastKnownState?.projects.find((candidate) => candidate.id === projectId) ?? loadDraftProject(draft); + if (!local) { + throw new Error('The local project could not be combined with the saved workspace.'); + } + const ours = splitProjectDocument(serializeProjectDocumentV3Json(local).document); + const theirs = splitProjectDocument(serializeProjectDocumentV3Json(serverProject).document); + if (ours.contentJson !== theirs.contentJson && draft.baseRevision !== record.revision) { + await markConflict( + projectId, + draft.generation, + { kind: 'revision', serverRevision: record.revision }, + store, + editorSessionId + ); + reportSingleProjectSync(projectId); + throw new Error('The project changed elsewhere as well. Choose again which version to keep.'); + } + const merged = deserializeProjectRecord({ + ...record, + data: ours.content as unknown as Record, + }); + if (merged.status !== 'loaded') { + throw new Error('The local project could not be combined with the saved workspace.'); + } + const previousConflict = conflicts.get(projectId); + const previousEntry = syncEntries.get(projectId); + const resumed = await store.resumeDraft(projectId, editorSessionId, writerToken, draft.generation); + if (resumed.kind !== 'marked') { + throw new Error('The local project draft could not be resumed.'); + } + try { + syncEntries.set(projectId, toSyncEntry(record, serverProject)); + conflicts.delete(projectId); + schemaRefusals.delete(projectId); + volatileDrafts.set(projectId, resumed.draft); + const staged = await stageProject(merged.project); + const rebased = await store.rebaseDraft(projectId, editorSessionId, writerToken, staged.generation, { + baseMinimumCanvasSchemaVersion: record.minimum_canvas_schema_version, + baseRevision: record.revision, + baseWorkspaceRevision: record.workspace?.revision ?? null, + }); + if (rebased.kind !== 'rebased') { + throw new Error('The local project draft could not be rebased onto the saved workspace.'); + } + } catch (error) { + if (previousEntry) { + syncEntries.set(projectId, previousEntry); + } + if (previousConflict?.kind === 'revision') { + await markConflict( + projectId, + draft.generation, + { + ...(previousConflict.half ? { half: previousConflict.half } : {}), + kind: 'revision', + serverRevision: previousConflict.serverRevision ?? record.revision, + }, + store, + editorSessionId + ); + } + reportSingleProjectSync(projectId); + throw error; + } + pendingProjectIds.add(projectId); + hasPending = true; + resolveProjectSyncConflict(projectId, { projectId, revision: record.revision }, hasPending); + projectResolutionFences.set(projectId, { kind: 'replace', project: merged.project }); + if (lastKnownState) { + lastKnownState = applyProjectResolutionFences(lastKnownState); + } + return merged; + }; + const alignLoadedDraftWithProject = async ( draft: ProjectDraft, project: Project, @@ -1719,15 +2164,15 @@ export const createDurableSyncedWorkbenchPersistence = ( } } - let record: ProjectRecordDTO; + let record: ProjectWithWorkspaceDTO; try { record = await api.getProject(projectId, owner.signal); assertOwner(); } catch (error) { assertOwner(); - const compatibility = getProjectCanvasSchemaCompatibilityRefusal(error); - if (compatibility && loadedDraft) { - const refusal = toCanvasSchemaRefusal(compatibility); + const readRefusal = toSchemaRefusal(error); + if (readRefusal && loadedDraft) { + const refusal = readRefusal; loadedDraft.draft = await alignLoadedDraftWithProject(loadedDraft.draft, loadedDraft.project, store); schemaRefusals.set(projectId, refusal); await store.settleSchemaRefusal( @@ -1760,22 +2205,8 @@ export const createDurableSyncedWorkbenchPersistence = ( reportOwnedProjectSync(); return { project: loadedDraft.project, status: 'loaded' }; } - if (compatibility) { - return { - refused: { - projectId, - projectName, - raw: null, - refusal: { - raw: null, - scope: 'document', - status: 'unsupported-version', - version: compatibility.minimumCanvasSchemaVersion, - }, - source: 'canvas', - }, - status: 'refused', - }; + if (readRefusal?.kind === 'canvas' || readRefusal?.kind === 'document') { + return { refused: toRefusedProject(projectId, projectName, readRefusal), status: 'refused' }; } return { status: 'unavailable' }; } @@ -1815,13 +2246,11 @@ export const createDurableSyncedWorkbenchPersistence = ( return { project: localProject, status: 'loaded' }; } - const serverJson = serializeProjectDocumentV3Json(result.project).documentJson; - syncEntries.set(projectId, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: serverJson, - revision: record.revision, - }); - if (recordRequiresDocumentUpgrade(record)) { + const serverEntry = toSyncEntry(record, result.project); + syncEntries.set(projectId, serverEntry); + // Another page may have split it since this one learned it was whole. + projectsRequiringDocumentUpgrade.delete(projectId); + if (recordRequiresDocumentUpgrade(record, result.project)) { projectsRequiringDocumentUpgrade.add(projectId); pendingProjectIds.add(projectId); hasPending = true; @@ -1833,39 +2262,36 @@ export const createDurableSyncedWorkbenchPersistence = ( const localProject = withAuthoritativeProjectBoard(loadedDraft.project, record.board_id); loadedDraft.draft = await alignLoadedDraftWithProject(loadedDraft.draft, localProject, store); - if (loadedDraft.draft.documentJson === serverJson) { + if (loadedDraft.draft.documentJson === serverEntry.pushedDoc) { await store.settleAcknowledgement( projectId, loadedDraft.draft.editorSessionId, writerToken, loadedDraft.draft.generation, record.revision, - record.minimum_canvas_schema_version + record.minimum_canvas_schema_version, + serverEntry.workspaceRevision ); volatileDrafts.delete(projectId); pendingProjectIds.delete(projectId); reportOwnedProjectSync(); return result; } + const divergence = + loadedDraft.draft.state === 'conflict' + ? loadedDraft.draft.conflict + : findDraftDivergence(loadedDraft.draft, result.project, record); if ( loadedDraft.draft.state === 'schema-refused' && loadedDraft.draft.refusal.kind === 'canvas' && !isCanvasSchemaVersionSupported(loadedDraft.draft.refusal.minimumCanvasSchemaVersion) ) { schemaRefusals.set(projectId, loadedDraft.draft.refusal); - } else if (loadedDraft.draft.state === 'conflict' || loadedDraft.draft.baseRevision !== record.revision) { - await markConflict( - projectId, - loadedDraft.draft.generation, - loadedDraft.draft.state === 'conflict' - ? loadedDraft.draft.conflict - : { kind: 'revision', serverRevision: record.revision }, - store, - loadedDraft.draft.editorSessionId - ); + } else if (divergence) { + await markConflict(projectId, loadedDraft.draft.generation, divergence, store, loadedDraft.draft.editorSessionId); } else { if (loadedDraft.draft.state === 'schema-refused') { - await store.resumeSchemaRefused( + await store.resumeDraft( projectId, loadedDraft.draft.editorSessionId, writerToken, @@ -1893,14 +2319,12 @@ export const createDurableSyncedWorkbenchPersistence = ( } const result = deserializeProjectRecord(record); if (result.status === 'loaded') { - syncEntries.set(record.project_id, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: serializeProjectDocumentV3Json(result.project).documentJson, - revision: record.revision, - }); + syncEntries.set(record.project_id, toSyncEntry(record, result.project)); conflicts.delete(record.project_id); schemaRefusals.delete(record.project_id); - if (recordRequiresDocumentUpgrade(record)) { + // Another page may have split it since this one learned it was whole. + projectsRequiringDocumentUpgrade.delete(record.project_id); + if (recordRequiresDocumentUpgrade(record, result.project)) { projectsRequiringDocumentUpgrade.add(record.project_id); pendingProjectIds.add(record.project_id); } @@ -1945,6 +2369,7 @@ export const createDurableSyncedWorkbenchPersistence = ( seedProjectLibrary([], owner); } syncEntries.clear(); + sentHalves.clear(); conflicts.clear(); schemaRefusals.clear(); pendingProjectIds.clear(); @@ -2052,6 +2477,7 @@ export const createDurableSyncedWorkbenchPersistence = ( volatileDrafts.delete(resolvedProjectId); draftEditorSessionIds.delete(resolvedProjectId); syncEntries.delete(resolvedProjectId); + sentHalves.delete(resolvedProjectId); conflicts.delete(resolvedProjectId); schemaRefusals.delete(resolvedProjectId); projectsRequiringDocumentUpgrade.delete(resolvedProjectId); @@ -2195,6 +2621,7 @@ export const createDurableSyncedWorkbenchPersistence = ( baseMinimumCanvasSchemaVersion: entry?.minimumCanvasSchemaVersion ?? getProjectCanvasSchemaRequirement(serialized.document), baseRevision: entry?.revision ?? null, + baseWorkspaceRevision: entry?.workspaceRevision ?? null, documentByteSize: serialized.byteSize, documentJson: serialized.documentJson, documentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, @@ -2322,6 +2749,7 @@ export const createDurableSyncedWorkbenchPersistence = ( } assertOwner(); syncEntries.clear(); + sentHalves.clear(); conflicts.clear(); schemaRefusals.clear(); projectsRequiringDocumentUpgrade.clear(); @@ -2379,7 +2807,7 @@ export const createDurableSyncedWorkbenchPersistence = ( ]); const draftCandidatesByProject = groupDraftCandidatesByProject(draftCandidates); const summaryById = new Map(summaries.map((summary) => [summary.project_id, summary])); - const handoffRecordsById = new Map(); + const handoffRecordsById = new Map(); const handoffTargetIds = new Set(); for (const handoff of retargetHandoffs) { if (!summaryById.has(handoff.targetProjectId)) { @@ -2492,11 +2920,20 @@ export const createDurableSyncedWorkbenchPersistence = ( volatileDrafts.set(projectId, draft); } const summary = summaryById.get(projectId); - const summaryIsIncompatible = Boolean( - summary && !isCanvasSchemaVersionSupported(summary.minimum_canvas_schema_version) - ); - let record: ProjectRecordDTO | null = null; - if (summary && isCanvasSchemaVersionSupported(summary.minimum_canvas_schema_version)) { + // The listing already says what a read would refuse: neither floor is worth a request to learn again. + const summaryRefusal: Parameters[2] | null = !summary + ? null + : !isCanvasSchemaVersionSupported(summary.minimum_canvas_schema_version) + ? { + kind: 'canvas', + maxCanvasSchemaVersion: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, + minimumCanvasSchemaVersion: summary.minimum_canvas_schema_version, + } + : summary.minimum_document_schema_version > PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION + ? toDocumentSchemaRefusal(summary.minimum_document_schema_version) + : null; + let record: ProjectWithWorkspaceDTO | null = null; + if (summary && !summaryRefusal) { try { record = handoffRecordsById.get(projectId) ?? (await api.getProject(projectId, owner.signal)); } catch (error) { @@ -2504,19 +2941,8 @@ export const createDurableSyncedWorkbenchPersistence = ( throw new WorkbenchBackendUnavailableError(error); } } - } else if (summary) { - refusedProjects.push({ - projectId, - projectName: summary.name, - raw: null, - refusal: { - raw: null, - scope: 'document', - status: 'unsupported-version', - version: summary.minimum_canvas_schema_version, - }, - source: 'canvas', - }); + } else if (summary && summaryRefusal) { + refusedProjects.push(toRefusedProject(projectId, summary.name, summaryRefusal)); } let loadedDraft: LoadedDraft | null = null; @@ -2547,13 +2973,8 @@ export const createDurableSyncedWorkbenchPersistence = ( return; } loadedDraft.draft = await alignLoadedDraftWithProject(loadedDraft.draft, loadedDraft.project, store); - if (summaryIsIncompatible) { - const minimumCanvasSchemaVersion = summary!.minimum_canvas_schema_version; - const refusal: ProjectSchemaRefusal = { - kind: 'canvas', - maxCanvasSchemaVersion: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, - minimumCanvasSchemaVersion, - }; + if (summaryRefusal) { + const refusal = summaryRefusal; schemaRefusals.set(projectId, refusal); await store.settleSchemaRefusal( projectId, @@ -2615,13 +3036,12 @@ export const createDurableSyncedWorkbenchPersistence = ( if (serverLoad.status !== 'loaded') { return; } - const serverJson = serializeProjectDocumentV3Json(serverLoad.project).documentJson; - syncEntries.set(projectId, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: serverJson, - revision: record.revision, - }); - if (recordRequiresDocumentUpgrade(record)) { + const serverEntry = toSyncEntry(record, serverLoad.project); + const serverJson = serverEntry.pushedDoc; + syncEntries.set(projectId, serverEntry); + // Another page may have split it since this one learned it was whole. + projectsRequiringDocumentUpgrade.delete(projectId); + if (recordRequiresDocumentUpgrade(record, serverLoad.project)) { projectsRequiringDocumentUpgrade.add(projectId); pendingProjectIds.add(projectId); hasPending = true; @@ -2639,7 +3059,8 @@ export const createDurableSyncedWorkbenchPersistence = ( writerToken, loadedDraft.draft.generation, record.revision, - record.minimum_canvas_schema_version + record.minimum_canvas_schema_version, + serverEntry.workspaceRevision ); pendingProjectIds.delete(projectId); loadedProjects.set(projectId, serverLoad.project); @@ -2647,26 +3068,55 @@ export const createDurableSyncedWorkbenchPersistence = ( } // The journal replaced a staged draft whose save reached the server without its answer reaching the page: // the server holds exactly that document, so the journal is its successor, as if the answer had arrived. + // Judged half by half: of a split save, one half may have landed and not the other. Both sides are judged + // in the form a load gives the server's copy, with the project's board applied: a first save's draft could + // not carry the board, which only the create's lost answer named. const replaced = journalReplacedDrafts.get(projectId)?.replacedDraft; if ( - replaced?.documentJson === serverJson && + replaced && journalReplacedDrafts.get(projectId)?.editorSessionId === loadedDraft.draft.editorSessionId && - loadedDraft.draft.state === 'dirty' && - loadedDraft.draft.baseRevision !== record.revision + loadedDraft.draft.state === 'dirty' ) { - const rebased = await store.settleAcknowledgement( - projectId, - loadedDraft.draft.editorSessionId, - writerToken, - replaced.generation, - record.revision, - record.minimum_canvas_schema_version - ); - if (rebased.kind === 'rebased') { - loadedDraft.draft = rebased.draft; - volatileDrafts.set(projectId, rebased.draft); + const landedDocument = applyAuthoritativeProjectBoard( + JSON.parse(replaced.documentJson) as Record, + record.board_id, + { selectBoard: false } + ) as unknown as ProjectDocumentV3; + const landed = splitProjectDocument(landedDocument); + const bases: ProjectDraftBases = {}; + const contentLanded = serverEntry.pushedContent + ? landed.contentJson === serverEntry.pushedContent + : JSON.stringify(landedDocument) === serverJson; + if (contentLanded && loadedDraft.draft.baseRevision !== record.revision) { + bases.baseRevision = record.revision; + bases.baseMinimumCanvasSchemaVersion = record.minimum_canvas_schema_version; + } + if ( + serverEntry.pushedWorkspace !== null && + landed.workspaceJson === serverEntry.pushedWorkspace && + (loadedDraft.draft.baseWorkspaceRevision ?? null) !== serverEntry.workspaceRevision + ) { + bases.baseWorkspaceRevision = serverEntry.workspaceRevision; + } + const rebased = + Object.keys(bases).length === 0 + ? null + : await store.rebaseDraft( + projectId, + loadedDraft.draft.editorSessionId, + writerToken, + replaced.generation, + bases + ); + if (rebased?.kind === 'rebased') { + loadedDraft.draft = withBases(loadedDraft.draft, bases); + volatileDrafts.set(projectId, loadedDraft.draft); } } + const divergence = + loadedDraft.draft.state === 'conflict' + ? loadedDraft.draft.conflict + : findDraftDivergence(loadedDraft.draft, serverLoad.project, record); if ( loadedDraft.draft.state === 'schema-refused' && loadedDraft.draft.refusal.kind === 'canvas' && @@ -2675,11 +3125,8 @@ export const createDurableSyncedWorkbenchPersistence = ( schemaRefusals.set(projectId, loadedDraft.draft.refusal); pendingProjectIds.add(projectId); hasPending = true; - } else if (loadedDraft.draft.state === 'conflict' || loadedDraft.draft.baseRevision !== record.revision) { - const conflict = - loadedDraft.draft.state === 'conflict' - ? loadedDraft.draft.conflict - : ({ kind: 'revision', serverRevision: record.revision } as const); + } else if (divergence) { + const conflict = divergence; await markConflict( projectId, loadedDraft.draft.generation, @@ -2689,7 +3136,7 @@ export const createDurableSyncedWorkbenchPersistence = ( ); } else { if (loadedDraft.draft.state === 'schema-refused') { - await store.resumeSchemaRefused( + await store.resumeDraft( projectId, loadedDraft.draft.editorSessionId, writerToken, @@ -3023,6 +3470,7 @@ export const createDurableSyncedWorkbenchPersistence = ( } const settlement = await store.retargetAcknowledgedCopy({ acknowledgedRevision: record.revision, + acknowledgedWorkspaceRevision: record.workspace?.revision ?? null, copyProjectId, editorSessionId, projectId, @@ -3042,6 +3490,7 @@ export const createDurableSyncedWorkbenchPersistence = ( latestVolatile && latestVolatile.generation > reservation.copyProjectGeneration ? toDirtyProjectDraft(latestVolatile, { baseRevision: record.revision, + baseWorkspaceRevision: record.workspace?.revision ?? null, copyDocumentByteSize: undefined, copyDocumentJson: undefined, copyProjectGeneration: undefined, @@ -3057,18 +3506,17 @@ export const createDurableSyncedWorkbenchPersistence = ( if (volatileRetargeted) { volatileDrafts.set(copyProjectId, volatileRetargeted); } - const acknowledgedJson = JSON.stringify(record.data); + const loadedCopy = deserializeProjectRecord(record); syncEntries.delete(projectId); + sentHalves.delete(projectId); conflicts.delete(projectId); projectsRequiringDocumentUpgrade.delete(projectId); schemaRefusals.delete(projectId); pendingProjectIds.delete(projectId); retargetedProjects.delete(projectId); - syncEntries.set(copyProjectId, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: acknowledgedJson, - revision: record.revision, - }); + if (loadedCopy.status === 'loaded') { + syncEntries.set(copyProjectId, toSyncEntry(record, loadedCopy.project)); + } upsertProjectSummary( { id: copyProjectId, @@ -3078,7 +3526,7 @@ export const createDurableSyncedWorkbenchPersistence = ( }, owner ); - recordProjectCover(copyProjectId, selectCoverImageName(record.data), owner); + recordProjectCover(copyProjectId, selectCoverImageName(document), owner); const durableRetargeted = settlement.kind === 'retargeted' ? settlement.draft : null; const survivingDraft = durableRetargeted && volatileRetargeted @@ -3115,7 +3563,6 @@ export const createDurableSyncedWorkbenchPersistence = ( }); recomputeHasPending(); resolveProjectSyncConflict(projectId, { projectId: copyProjectId, revision: record.revision }, hasPending); - const loadedCopy = deserializeProjectRecord(record); const visible = survivingDraft ? loadDraftProject(survivingDraft) : loadedCopy.status === 'loaded' @@ -3158,13 +3605,18 @@ export const createDurableSyncedWorkbenchPersistence = ( projectResolutionFences.delete(projectId); return result; } + const conflict = conflicts.get(projectId); + // Without a workspace row (one never saved, or since removed) the merge takes the one a new project starts with. + if (conflict?.kind === 'revision' && conflict.half === 'workspace' && isSplitProjectRecord(record)) { + return useServerWorkspace(projectId, record, result.project); + } await deleteDraft(projectId); - const documentJson = serializeProjectDocumentV3Json(result.project).documentJson; - syncEntries.set(projectId, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: documentJson, - revision: record.revision, - }); + syncEntries.set(projectId, toSyncEntry(record, result.project)); + if (recordRequiresDocumentUpgrade(record, result.project)) { + projectsRequiringDocumentUpgrade.add(projectId); + } else { + projectsRequiringDocumentUpgrade.delete(projectId); + } conflicts.delete(projectId); schemaRefusals.delete(projectId); pendingProjectIds.delete(projectId); @@ -3200,6 +3652,7 @@ export const createDurableSyncedWorkbenchPersistence = ( return; } syncEntries.delete(projectId); + sentHalves.delete(projectId); conflicts.delete(projectId); schemaRefusals.delete(projectId); generations.delete(projectId); diff --git a/invokeai/frontend/webv2/src/workbench/projects/indexedDbDraftStore.ts b/invokeai/frontend/webv2/src/workbench/projects/indexedDbDraftStore.ts index 635b9d32b2f..1ab6e2c4bf3 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/indexedDbDraftStore.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/indexedDbDraftStore.ts @@ -27,6 +27,7 @@ import { PROJECT_DRAFT_PROJECT_LIMIT, toConflictProjectDraft, toDirtyProjectDraft, + withBases, toProjectDraftBody, toProjectDraftMetadata, toSchemaRefusedProjectDraft, @@ -45,6 +46,7 @@ import { type ProjectDraftRetargetAcknowledgeResult, type ProjectDraftRetargetHandoff, type ProjectDraftRetargetListResult, + type ProjectDraftRebaseResult, type ProjectDraftSettlementResult, type ProjectDraftStageResult, type ProjectDraftStartWriterResult, @@ -262,7 +264,25 @@ export const createIndexedDbProjectDraftStore = ( transform: DraftTransform, kind: 'marked' | 'rebased' ): Promise => { - const outcome = await mutate( + const outcome = await settleMetadata(projectId, editorSessionId, writerToken, sentGeneration, transform); + if (outcome.kind !== 'read-marked') { + return outcome; + } + const read = await readDraft(projectId, editorSessionId); + if (read.kind === 'found') { + return read.draft.writerToken === writerToken ? { draft: read.draft, kind } : { kind: 'fenced' }; + } + return read.kind === 'corrupt' || read.kind === 'unavailable' ? read : { kind: 'missing' }; + }; + /** The settlement's metadata write alone, for a caller that needs no draft back: the body is not read. */ + const settleMetadata = ( + projectId: string, + editorSessionId: string, + writerToken: string, + sentGeneration: number, + transform: DraftTransform + ): Promise => + mutate( async () => { const transaction = observeTransaction(database.transaction(DRAFT_STORES, 'readwrite')); const metadataStore = transaction.objectStore(WORKBENCH_DRAFT_STORE); @@ -314,15 +334,6 @@ export const createIndexedDbProjectDraftStore = ( { kind: 'unavailable' }, { kind: 'quota' } ); - if (outcome.kind !== 'read-marked') { - return outcome; - } - const read = await readDraft(projectId, editorSessionId); - if (read.kind === 'found') { - return read.draft.writerToken === writerToken ? { draft: read.draft, kind } : { kind: 'fenced' }; - } - return read.kind === 'corrupt' || read.kind === 'unavailable' ? read : { kind: 'missing' }; - }; return { get availability() { @@ -961,7 +972,13 @@ export const createIndexedDbProjectDraftStore = ( { kind: 'quota' } ); }, - resumeSchemaRefused(projectId, editorSessionId, writerToken, generation) { + async rebaseDraft(projectId, editorSessionId, writerToken, generation, bases) { + const settled = await settleMetadata(projectId, editorSessionId, writerToken, generation, (draft) => + withBases(draft, bases) + ); + return settled.kind === 'read-marked' ? { kind: 'rebased' } : (settled as ProjectDraftRebaseResult); + }, + resumeDraft(projectId, editorSessionId, writerToken, generation) { return settle( projectId, editorSessionId, @@ -1055,6 +1072,7 @@ export const createIndexedDbProjectDraftStore = ( ? null : toDirtyProjectDraft(read.draft, { baseRevision: options.acknowledgedRevision, + baseWorkspaceRevision: options.acknowledgedWorkspaceRevision, copyDocumentByteSize: undefined, copyDocumentJson: undefined, copyProjectId: undefined, @@ -1110,7 +1128,8 @@ export const createIndexedDbProjectDraftStore = ( writerToken, sentGeneration, acknowledgedRevision, - acknowledgedMinimumCanvasSchemaVersion + acknowledgedMinimumCanvasSchemaVersion, + acknowledgedWorkspaceRevision ) { const outcome = await mutate( async () => { @@ -1165,6 +1184,9 @@ export const createIndexedDbProjectDraftStore = ( baseMinimumCanvasSchemaVersion: acknowledgedMinimumCanvasSchemaVersion ?? metadata.baseMinimumCanvasSchemaVersion, baseRevision: acknowledgedRevision, + ...(acknowledgedWorkspaceRevision === undefined + ? {} + : { baseWorkspaceRevision: acknowledgedWorkspaceRevision }), metadataRevision: nextMetadataRevision, }); await writerStore.put( diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.test.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.test.ts index 719a2571045..eb8a4ae1da1 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.test.ts @@ -1,5 +1,5 @@ import type { AccountScope } from '@platform/state/accountLifecycle'; -import type { ProjectBoardItemDTO, ProjectRecordDTO } from '@workbench/projects/api'; +import type { ProjectBoardItemDTO, ProjectWithWorkspaceDTO } from '@workbench/projects/api'; import type * as apiModule from '@workbench/projects/api'; import { stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; @@ -67,7 +67,7 @@ const rasterImageLayer = (id: string, imageName: string) => ({ type: 'raster' as const, }); -const sourceRecord = (document: Record = {}): ProjectRecordDTO => { +const sourceRecord = (document: Record = {}): ProjectWithWorkspaceDTO => { const project = createDraftProject([]); return { @@ -85,9 +85,12 @@ const sourceRecord = (document: Record = {}): ProjectRecordDTO }, name: 'Source', minimum_canvas_schema_version: 2, + minimum_document_schema_version: 3, project_id: 'source', revision: 4, updated_at: '2026-06-10 10:00:00.000', + workspace_updated_at: null, + workspace: null, }; }; @@ -132,6 +135,7 @@ beforeEach(async () => { minimum_canvas_schema_version?: number; name: string; project_id: string; + workspace?: Record; }) => Promise.resolve({ board_id: request.board_id ?? 'server-created-board', @@ -142,6 +146,9 @@ beforeEach(async () => { project_id: request.project_id, revision: 1, updated_at: '2026-06-10 11:00:00.000', + workspace: request.workspace + ? { data: request.workspace, revision: 1, updated_at: '2026-06-10 11:00:00.000' } + : null, }) ); @@ -224,12 +231,21 @@ describe('duplicateProjectRecord', () => { /** Same-server duplicates share external references. */ it('reuses a document reference the board does not own, copying nothing', async () => { - const record = sourceRecord({ futureImageInput: { image_name: 'external.png' } }); + const project = createDraftProject([]); + const record = sourceRecord({ + canvas: { + ...project.canvas, + document: { + ...project.canvas.document, + stacks: stacksFrom([rasterImageLayer('l1', 'shared.png'), rasterImageLayer('l2', 'external.png')]), + }, + }, + }); await duplicateProject.duplicateProjectRecord({ boards: inboxOf([boardItem()]), owner, record }); expect(transport.copyImagesToBoard).toHaveBeenCalledWith(['shared.png'], 'staging-board', owner.signal); - expect((createdData().futureImageInput as { image_name: string }).image_name).toBe('external.png'); + expect(JSON.stringify(createdData().canvas)).toContain('"external.png"'); }); /** A failed copy must never fall back to source-owned media. */ @@ -249,24 +265,73 @@ describe('duplicateProjectRecord', () => { }); it('canonicalizes the copy under its own identity and drops installation state', async () => { + const project = createDraftProject([]); + const gallery = Object.values(project.widgetInstances).find((instance) => instance.typeId === 'gallery')!; const record = sourceRecord({ - widgetStates: { gallery: { values: { projectBoardId: 'source-board', selectedBoardId: 'source-board' } } }, + widgetInstances: { + ...project.widgetInstances, + [gallery.id]: { + ...gallery, + state: { + ...gallery.state, + values: { ...gallery.state.values, projectBoardId: 'source-board', selectedBoardId: 'source-board' }, + }, + }, + }, }); const result = await duplicateProject.duplicateProjectRecord({ boards: inboxOf([boardItem()]), owner, record }); - const gallery = (createdData().widgetStates as { gallery: { values: Record } }).gallery.values; - // The document that goes to the server carries neither the original's board nor its selection. - expect(gallery.projectBoardId).toBeUndefined(); - expect(gallery.selectedBoardId).toBeUndefined(); + // The content carries no gallery state at all, and is the copy's under its own identity. + expect(createdData().widgetInstances).toBeUndefined(); expect(createdData().id).toBe(result.record.project_id); expect(createdData().name).toBe('Source copy'); + // The duplicating account's workspace goes in the same create, opening on the staged inbox it claims, never the + // original's board; the record answers with the workspace the create stored. + expect(api.createProjectSettled).toHaveBeenCalledOnce(); + const sent = (api.createProjectSettled.mock.calls[0]![0] as { workspace: Record }).workspace; + expect((sent.widgetInstances as typeof project.widgetInstances)[gallery.id]!.state.values).toMatchObject({ + projectBoardId: 'staging-board', + selectedBoardId: 'staging-board', + }); + expect(api.createProjectSettled.mock.calls[0]![0]).toMatchObject({ + board_id: 'staging-board', + minimum_document_schema_version: 4, + }); + expect(result.record.workspace).toMatchObject({ data: sent, revision: 1 }); + }); + + it('stages an inbox for a source whose snapshot lists no boards, so the create still carries both halves', async () => { + const result = await duplicateProject.duplicateProjectRecord({ boards: [], owner, record: sourceRecord() }); + + expect(transport.createStagingBoard).toHaveBeenCalledExactlyOnceWith('Source copy', owner.signal); + expect(api.createProjectSettled.mock.calls[0]![0]).toMatchObject({ + board_id: 'staging-board', + workspace: expect.any(Object), + }); + expect(result.record.workspace).not.toBeNull(); + }); + + it('copies a project whose workflow repeats an item id whole, as no load would accept it split', async () => { + const project = createDraftProject([]); + const node = { data: { label: '', notes: '' }, id: 'node-1', position: { x: 0, y: 0 }, type: 'notes' }; + const record = sourceRecord({ + documentSchemaVersion: 3, + workflows: { + ...project.workflows, + entries: project.workflows.entries.map((entry) => ({ + ...entry, + document: { ...entry.document, nodes: [node, node] }, + })), + }, + }); - // The returned record is patched with the board the server says it claimed, and points at it. - const claimed = (result.record.data.widgetStates as { gallery: { values: Record } }).gallery - .values; + const result = await duplicateProject.duplicateProjectRecord({ boards: inboxOf([]), owner, record }); - expect(claimed).toMatchObject({ projectBoardId: 'staging-board', selectedBoardId: 'staging-board' }); + expect(api.createProjectSettled.mock.calls[0]![0]).toMatchObject({ minimum_document_schema_version: 3 }); + expect(api.createProjectSettled.mock.calls[0]![0]).not.toHaveProperty('workspace'); + expect(createdData()).toMatchObject({ documentSchemaVersion: 3, widgetInstances: expect.any(Object) }); + expect(result.record.workspace).toBeNull(); }); it('keeps each workflow linked to its library template, since the copy lives on the same server', async () => { @@ -285,7 +350,7 @@ describe('duplicateProjectRecord', () => { const workflows = createdData().workflows as { entries: { source?: unknown }[] }; expect(workflows.entries[0]?.source).toEqual(source); - expect(createdData().documentSchemaVersion).toBe(3); + expect(createdData().documentSchemaVersion).toBe(4); }); it('stages, copies and places every other board of the source, empty ones included', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.ts index f01bdb35b1c..0babac2470a 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.ts @@ -1,4 +1,5 @@ -import type { ProjectBoardSnapshotBoardDTO, ProjectRecordDTO } from '@workbench/projects/api'; +import type { ProjectBoardSnapshotBoardDTO, ProjectWithWorkspaceDTO } from '@workbench/projects/api'; +import type { ProjectDocumentV3 } from '@workbench/projects/projectDocument'; import { type AccountScope, assertAccountScopeCurrent, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { createProjectSettled, invalidateBoardLists } from '@workbench/projects/api'; @@ -15,8 +16,8 @@ import type { ProjectTransferIssues } from './transfer'; import { type CopyMediaResult, copyImagesToBoard, copyVideosToBoard, isRequestCancellation } from './assetTransport'; import { INVK_MAX_BOARDS, type InvkBoard } from './board'; -import { InvkFormatError, toInvkFormatReason } from './format'; -import { createStagingBoards, findInboxStagingBoardId, placeMemberBoards } from './memberBoards'; +import { InvkFormatError, toInvkRefusalError } from './format'; +import { createStagingBoards, placeMemberBoards } from './memberBoards'; import { createRestoredMediaLedger, restoreProjectMedia, @@ -24,6 +25,7 @@ import { rollbackUnlessProjectExists, } from './restoreProjectMedia'; import { toMediaRefs } from './transfer'; +import { toTransferredProjectCreation } from './transferCreation'; /** Copy board media server-side and reuse external reference identities. */ @@ -32,7 +34,7 @@ export interface DuplicateProjectInput { boards: readonly ProjectBoardSnapshotBoardDTO[]; owner: AccountScope; /** The acknowledged source record — for an open project, flushed first. */ - record: ProjectRecordDTO; + record: ProjectWithWorkspaceDTO; /** Stable identity reserved before the operation, used by retry-safe conflict copies. */ identity?: { id: string; name: string }; } @@ -47,7 +49,7 @@ export interface DuplicateProjectDeps { export interface DuplicateProjectResult extends ProjectTransferIssues { /** The cover to record for the copy, or `null` when the source had none this server can serve. */ coverImageName: string | null; - record: ProjectRecordDTO; + record: ProjectWithWorkspaceDTO; } /** @@ -111,15 +113,20 @@ export const duplicateProjectRecord = async ( assertAccountScopeCurrent(owner); - // Canonicalize with a fresh project identity and reset source board/selection state. - const { deserializeProjectDocument } = await import('@workbench/projects/syncedPersistence'); + // Canonicalize with a fresh project identity and reset source board/selection state. The copy carries the + // duplicating account's workspace, never another member's: the source record's is the caller's own. + const { deserializeProjectDocument, joinProjectRecord } = await import('@workbench/projects/projectHydration'); assertAccountScopeCurrent(owner); - const loaded = deserializeProjectDocument({ ...stripInstallationState(input.record.data), id, name }); + const source = joinProjectRecord(input.record); + if (source.status === 'malformed') { + throw new InvkFormatError('damaged', source.reason); + } + const loaded = deserializeProjectDocument({ ...stripInstallationState(source.document), id, name }); if (loaded.status === 'refused') { - throw new InvkFormatError(toInvkFormatReason(loaded.refused), 'The project document was refused.'); + throw toInvkRefusalError(loaded.refused); } if (loaded.status !== 'loaded') { @@ -128,8 +135,7 @@ export const duplicateProjectRecord = async ( const project = loaded.project; - const { applyAuthoritativeProjectBoard, serializeProjectDocument } = - await import('@workbench/projects/projectDocument'); + const { serializeProjectDocument } = await import('@workbench/projects/projectDocument'); const canonicalDocument = serializeProjectDocument(project); const boards: InvkBoard[] = input.boards.map((board) => ({ archived: board.archived, @@ -154,8 +160,7 @@ export const duplicateProjectRecord = async ( try { assertAccountScopeCurrent(owner); - const stagedBoards = await createStagingBoards(boards, name, ledger, owner.signal); - const inboxStagingBoardId = findInboxStagingBoardId(stagedBoards); + const { boards: stagedBoards, inboxStagingBoardId } = await createStagingBoards(boards, name, ledger, owner.signal); assertAccountScopeCurrent(owner); @@ -188,13 +193,15 @@ export const duplicateProjectRecord = async ( assertAccountScopeCurrent(owner); didAttemptProjectCreate = true; + const copied = remapAssetRefs(canonicalDocument, restored.mappings) as unknown as ProjectDocumentV3; + // The copy's content and the duplicating account's workspace, which opens on the inbox the create claims, in one + // request: the copy never exists without it. A copy created whole carries the workspace inside its document. const record = await createProjectSettled( { - data: remapAssetRefs(canonicalDocument, restored.mappings), + ...toTransferredProjectCreation(copied, inboxStagingBoardId), minimum_canvas_schema_version: input.record.minimum_canvas_schema_version, name, project_id: id, - ...(inboxStagingBoardId === null ? {} : { board_id: inboxStagingBoardId }), }, owner ); @@ -214,10 +221,7 @@ export const duplicateProjectRecord = async ( boardItemIssues: restored.boardItemIssues, coverImageName: restored.coverImageName, documentReferenceIssues: restored.documentReferenceIssues, - record: { - ...record, - data: applyAuthoritativeProjectBoard(record.data, record.board_id, { selectBoard: true }), - }, + record, }; } catch (error) { // The staging boards it deletes may already be listed: uploads to them announce themselves. diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/exportProject.test.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/exportProject.test.ts index b7cf503caea..8561c77c8b1 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/exportProject.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/exportProject.test.ts @@ -9,7 +9,7 @@ vi.mock('@platform/transport/http', async (importOriginal) => ({ import { INVK_MAX_ARCHIVE_BYTES, readArchive, readEntryText } from './archive'; import { executeInvkExport, planInvkExport } from './exportProject'; -import { INVK_DOCUMENT_ENTRY, INVK_MANIFEST_ENTRY } from './format'; +import { INVK_DOCUMENT_ENTRY, INVK_MANIFEST_ENTRY, INVK_WORKSPACE_ENTRY } from './format'; const imageRef = (imageName: string) => ({ height: 64, imageName, width: 64 }); @@ -40,8 +40,10 @@ const planInput = { boards: [{ archived: false, isInbox: true, items: [], name: 'My project' }], createdAt: '2026-08-04T00:00:00.000Z', minimumCanvasSchemaVersion: 3, + content: projectDocument(), + documentSchemaVersion: 4, name: 'My project', - projectDocument: projectDocument(), + workspace: null, }; /** The names a plan will fetch, in the union's stable order. */ @@ -68,7 +70,7 @@ describe('planInvkExport', () => { it('writes a document carrying no gallery selection', () => { const plan = planInvkExport({ ...planInput, - projectDocument: { + content: { ...projectDocument(), widgetInstances: { 'gallery-1': { @@ -85,8 +87,29 @@ describe('planInvkExport', () => { expect(plannedNames(plan)).toEqual(['live-a.png', 'live-b.png']); }); + it('carries the workspace, and the media only it names, only when it is included', () => { + const workspace = { + canvas: { selectedLayerId: null, stagingArea: { images: [imageRef('staged.png')] } }, + layout: {}, + widgetInstances: { + 'gallery-1': { state: { values: { recentImages: [imageRef('recent.png')] } }, typeId: 'gallery' }, + }, + }; + + const included = planInvkExport({ ...planInput, workspace }); + const left = planInvkExport(planInput); + + expect(plannedNames(included)).toEqual(['live-a.png', 'live-b.png', 'staged.png']); + expect(JSON.parse(included.workspaceJson!)).toMatchObject({ canvas: workspace.canvas }); + // The newest result is the workspace's to show; without it the cover is the canvas's. + expect(included.coverImageName).toBe('recent.png'); + expect(plannedNames(left)).toEqual(['live-a.png', 'live-b.png']); + expect(left.workspaceJson).toBeNull(); + expect(left.coverImageName).toBe('live-a.png'); + }); + it('omits the source project id when the document has none', () => { - const plan = planInvkExport({ ...planInput, projectDocument: { canvas: {}, layout: {}, name: 'x' } }); + const plan = planInvkExport({ ...planInput, content: { canvas: {}, layout: {}, name: 'x' } }); expect(plan.manifestInput.sourceProjectId).toBeUndefined(); expect(plannedNames(plan)).toEqual([]); @@ -136,9 +159,10 @@ describe('executeInvkExport', () => { contents: 'workbench-project', cover: 'cover.webp', name: 'My project', + documentSchemaVersion: 4, minimumCanvasSchemaVersion: 3, sourceProjectId: 'project-1', - version: 2, + version: 3, }); expect(JSON.parse(readEntryText(entries.get(INVK_DOCUMENT_ENTRY)!))).toEqual(projectDocument()); }); @@ -176,10 +200,26 @@ describe('executeInvkExport', () => { expect(result.boardItemIssues).toEqual([]); }); + it('writes the workspace beside the content when it is included', async () => { + const download = vi.fn(); + const workspace = { canvas: { selectedLayerId: null }, layout: { mode: 'kept' } }; + + await executeInvkExport(planInvkExport({ ...planInput, workspace }), { + download, + fetchImageBytes: () => Promise.resolve(null), + fetchImageThumbnail: () => Promise.resolve(null), + }); + + const [blob] = download.mock.calls[0]! as [Blob]; + const entries = await readArchive(new Uint8Array(await blob.arrayBuffer())); + + expect(JSON.parse(readEntryText(entries.get(INVK_WORKSPACE_ENTRY)!))).toEqual(workspace); + }); + it('omits the cover entry when there is no cover to fetch', async () => { const download = vi.fn(); - await executeInvkExport(planInvkExport({ ...planInput, projectDocument: { id: 'p', layout: {}, name: 'n' } }), { + await executeInvkExport(planInvkExport({ ...planInput, content: { id: 'p', layout: {}, name: 'n' } }), { download, fetchImageBytes: () => Promise.resolve(null), fetchImageThumbnail: () => Promise.resolve(null), @@ -197,7 +237,7 @@ describe('executeInvkExport', () => { const download = vi.fn(); const plan = planInvkExport({ ...planInput, - projectDocument: { + content: { ...projectDocument(), projectGraph: { nodes: [{ data: { inputs: { video: { video_name: 'clip.mp4' } } }, id: 'n' }] }, }, @@ -236,7 +276,7 @@ describe('executeInvkExport', () => { it('skips a video the server will not serve, exactly as it skips an image', async () => { const plan = planInvkExport({ ...planInput, - projectDocument: { + content: { ...projectDocument(), projectGraph: { nodes: [{ data: { inputs: { video: { video_name: 'clip.mp4' } } }, id: 'n' }] }, }, diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/exportProject.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/exportProject.ts index 4043f35c8a6..b310a9bbc72 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/exportProject.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/exportProject.ts @@ -27,6 +27,7 @@ import { INVK_IMAGES_PREFIX, INVK_MANIFEST_ENTRY, INVK_VIDEOS_PREFIX, + INVK_WORKSPACE_ENTRY, InvkFormatError, } from './format'; import { buildInvkManifest, toInvkFileName } from './manifest'; @@ -39,8 +40,10 @@ export interface InvkExportPlan { boardSnapshot: InvkBoardSnapshot; /** Cover image name, or `null` for a project that has produced nothing. */ coverImageName: string | null; - /** The project document, already serialized. */ + /** `project.json`, already serialized: the project content, or a whole document an exporter could not split. */ documentJson: string; + /** `workspace.json`, already serialized, or `null` when the exporter's workspace stays out of the file. */ + workspaceJson: string | null; /** Download file name, including the extension. */ fileName: string; fonts: InvkFontDependency[]; @@ -48,6 +51,7 @@ export interface InvkExportPlan { manifestInput: { appVersion: string; createdAt: string; + documentSchemaVersion: number; minimumCanvasSchemaVersion: number; name: string; sourceProjectId?: string; @@ -57,7 +61,7 @@ export interface InvkExportPlan { } /** Budget fixed entries separately from the optional cover. */ -const FIXED_ENTRY_COUNT = 3; +const FIXED_ENTRY_COUNT = 4; export const planInvkExport = (input: { appVersion: string; @@ -66,19 +70,28 @@ export const planInvkExport = (input: { createdAt: string; minimumCanvasSchemaVersion: number; name: string; - projectDocument: Record; + /** What `project.json` carries, and the document schema it is written in. */ + content: Record; + documentSchemaVersion: number; + /** The exporter's own workspace, or `null` to leave it out of the file. */ + workspace: Record | null; includeFonts?: boolean; }): InvkExportPlan => { - const sourceProjectId = typeof input.projectDocument.id === 'string' ? input.projectDocument.id : undefined; + const sourceProjectId = typeof input.content.id === 'string' ? input.content.id : undefined; // Strip installation state during planning; excluding its bytes alone leaves dangling references. - const projectDocument = stripInstallationState(input.projectDocument); + const content = stripInstallationState(input.content); + const workspace = input.workspace === null ? null : stripInstallationState(input.workspace); + const written = workspace === null ? [content] : [content, workspace]; + // Only what the file carries: media a left-out workspace names (staged results, gallery recents) stays behind. + const documentRefs = written.map(collectLiveAssetRefs).reduce((all, refs) => ({ + images: new Set([...all.images, ...refs.images]), + videos: new Set([...all.videos, ...refs.videos]), + })); const boardSnapshot = buildInvkBoardSnapshot(input.boards); - const transferItems = planMediaTransfer( - flattenInvkBoardItems(boardSnapshot), - toMediaRefs(collectLiveAssetRefs(projectDocument)) - ); - const fonts = collectFontDependencies(projectDocument); + const transferItems = planMediaTransfer(flattenInvkBoardItems(boardSnapshot), toMediaRefs(documentRefs)); + // A reader checks fonts against the canvas of each half it reads, the workspace's staging area included. + const fonts = collectFontDependencies({ canvas: written.map((half) => half.canvas) }); // Check the archive budget before downloading assets. const worstCaseEntries = FIXED_ENTRY_COUNT + transferItems.length + (input.includeFonts ? fonts.length : 0) + 1; @@ -89,20 +102,23 @@ export const planInvkExport = (input: { return { boardSnapshot, - coverImageName: selectCoverImageName(projectDocument), + // The gallery's newest result is the workspace's, so without it the cover comes from the canvas. + coverImageName: selectCoverImageName({ ...workspace, ...content }), // Serialize compactly to limit the uncompressed document size. - documentJson: JSON.stringify(projectDocument), + documentJson: JSON.stringify(content), fileName: toInvkFileName(input.name), fonts, includeFonts: input.includeFonts ?? false, manifestInput: { appVersion: input.appVersion, createdAt: input.createdAt, + documentSchemaVersion: input.documentSchemaVersion, minimumCanvasSchemaVersion: input.minimumCanvasSchemaVersion, name: input.name, ...(sourceProjectId === undefined ? {} : { sourceProjectId }), }, transferItems, + workspaceJson: workspace === null ? null : JSON.stringify(workspace), }; }; @@ -230,6 +246,9 @@ export const executeInvkExport = async (plan: InvkExportPlan, deps: InvkExportDe // The manifest is the one entry a person may open by hand, so it is indented. entries.set(INVK_MANIFEST_ENTRY, textEntry(JSON.stringify(manifest, null, 2))); entries.set(INVK_DOCUMENT_ENTRY, textEntry(plan.documentJson)); + if (plan.workspaceJson !== null) { + entries.set(INVK_WORKSPACE_ENTRY, textEntry(plan.workspaceJson)); + } // Write an explicit empty board entry and retain missing-byte descriptors for import reporting. entries.set(INVK_BOARD_ENTRY, textEntry(JSON.stringify(plan.boardSnapshot))); diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/fonts.test.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/fonts.test.ts index 19223157547..862d2072164 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/fonts.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/fonts.test.ts @@ -46,7 +46,9 @@ const planFor = async (includeFonts: boolean) => includeFonts, minimumCanvasSchemaVersion: 4, name: 'Fonts', - projectDocument: documentWith(await fontRef()), + content: documentWith(await fontRef()), + documentSchemaVersion: 4, + workspace: null, }); const transportFor = (): FontArchiveTransport => ({ download: vi.fn(() => Promise.resolve({ bytes, filename: 'Example.ttf' })), diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/format.test.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/format.test.ts new file mode 100644 index 00000000000..7b895676dbd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/format.test.ts @@ -0,0 +1,31 @@ +import type { RefusedWorkbenchProject } from '@workbench/projectContracts'; + +import { MIN_SUPPORTED_CANVAS_SCHEMA_VERSION } from '@workbench/canvasSchemaVersion'; +import { describe, expect, it } from 'vitest'; + +import { toInvkRefusalError } from './format'; + +const refused = (source: 'canvas' | 'project-document', version: number) => + ({ + projectId: 'p', + projectName: 'P', + raw: {}, + refusal: { raw: {}, scope: source, status: 'unsupported-version', version }, + source, + }) as unknown as RefusedWorkbenchProject; + +describe('toInvkRefusalError', () => { + it('names which schema refused the document, and an old canvas as retired rather than newer', () => { + expect(toInvkRefusalError(refused('project-document', 5))).toMatchObject({ + message: 'Project requires document schema 5.', + reason: 'unsupported-version', + }); + expect(toInvkRefusalError(refused('canvas', 99))).toMatchObject({ + message: 'Project requires canvas schema 99.', + reason: 'unsupported-version', + }); + expect(toInvkRefusalError(refused('canvas', MIN_SUPPORTED_CANVAS_SCHEMA_VERSION - 1))).toMatchObject({ + reason: 'legacy-canvas-project', + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/format.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/format.ts index 851c9a38e3a..5a7841385b8 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/format.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/format.ts @@ -7,13 +7,19 @@ import { MIN_SUPPORTED_CANVAS_SCHEMA_VERSION } from '@workbench/canvasSchemaVers export const INVK_EXTENSION = '.invk'; export const INVK_MIME_TYPE = 'application/zip'; -/** Version 2 is supported. Version 1 receives a named refusal; board.json is optional in v2. */ -export const INVK_VERSION = 2; +/** + * Version 3 is written: `project.json` is the project content, beside an optional `workspace.json`. Version 2, whose + * `project.json` is a whole document, still imports; version 1 receives a named refusal. board.json is optional. + */ +export const INVK_VERSION = 3; /** Fixed entry paths. `cover` varies by image format and is named in the manifest. */ export const INVK_MANIFEST_ENTRY = 'manifest.json'; export const INVK_DOCUMENT_ENTRY = 'project.json'; +/** The exporter's workspace (layout, generation settings, prompt history), when they chose to include it. */ +export const INVK_WORKSPACE_ENTRY = 'workspace.json'; + /** The project board's contents; see `board.ts` for why a reader cannot infer them. */ export const INVK_BOARD_ENTRY = 'board.json'; @@ -51,12 +57,19 @@ export class InvkFormatError extends Error { } } -/** The archive-format reason that matches a project the canvas version gate refused. */ -export const toInvkFormatReason = (refused: RefusedWorkbenchProject): InvkFormatReason => { +/** + * The archive error for a document the load refused. People see the reason ("made with a newer Invoke"); the message, + * kept for logs and error details, names which schema was too new. + */ +export const toInvkRefusalError = (refused: RefusedWorkbenchProject): InvkFormatError => { if (refused.refusal.status !== 'unsupported-version') { - return 'damaged'; + return new InvkFormatError('damaged', 'The project document was refused.'); + } + const { version } = refused.refusal; + if (refused.source === 'project-document') { + return new InvkFormatError('unsupported-version', `Project requires document schema ${version}.`); } - return refused.refusal.version < MIN_SUPPORTED_CANVAS_SCHEMA_VERSION - ? 'legacy-canvas-project' - : 'unsupported-version'; + return version < MIN_SUPPORTED_CANVAS_SCHEMA_VERSION + ? new InvkFormatError('legacy-canvas-project', `Project uses retired canvas schema ${version}.`) + : new InvkFormatError('unsupported-version', `Project requires canvas schema ${version}.`); }; diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/importProject.test.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/importProject.test.ts index 110bc49c631..9357b29fab7 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/importProject.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/importProject.test.ts @@ -95,6 +95,46 @@ describe('readInvkArchive', () => { expect(contents.cover).toBeNull(); }); + it('reads a v3 archive’s workspace beside its content, and none from an older archive', async () => { + const workspace = { canvas: { selectedLayerId: null }, layout: { mode: 'kept' } }; + const v3 = manifest({ documentSchemaVersion: 4, version: 3 }); + + const withWorkspace = await readInvkArchive( + await validArchive({ 'manifest.json': JSON.stringify(v3), 'workspace.json': JSON.stringify(workspace) }) + ); + const without = await readInvkArchive(await validArchive({ 'manifest.json': JSON.stringify(v3) })); + const older = await readInvkArchive(await validArchive({ 'workspace.json': JSON.stringify(workspace) })); + + expect(withWorkspace.workspace).toEqual(workspace); + expect(without.workspace).toBeNull(); + expect(older.workspace).toBeNull(); + await expect( + readInvkArchive(await validArchive({ 'manifest.json': JSON.stringify(v3), 'workspace.json': '[1]' })) + ).rejects.toMatchObject({ reason: 'damaged' }); + }); + + it('refuses a newer document or canvas schema by its manifest, before reading what it carries', async () => { + // Entries a newer build may shape differently: read first, they would look damaged. + const unreadable = { 'project.json': '{', 'workspace.json': '[1]' }; + + await expect( + readInvkArchive( + await validArchive({ + ...unreadable, + 'manifest.json': JSON.stringify(manifest({ documentSchemaVersion: 5, version: 3 })), + }) + ) + ).rejects.toMatchObject({ message: 'Project requires document schema 5.', reason: 'unsupported-version' }); + await expect( + readInvkArchive( + await validArchive({ + ...unreadable, + 'manifest.json': JSON.stringify(manifest({ minimumCanvasSchemaVersion: 99 })), + }) + ) + ).rejects.toMatchObject({ message: 'Project requires canvas schema 99.', reason: 'unsupported-version' }); + }); + /** Missing board entries retain legacy semantics; explicit empty entries describe an empty board. */ it('reads an archive with no board enumeration as naming no board', async () => { const contents = await readInvkArchive(await validArchive()); diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/importProject.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/importProject.ts index 519d0b50b08..641181ec687 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/importProject.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/importProject.ts @@ -1,4 +1,6 @@ import { mapWithConcurrency } from '@platform/core/concurrency'; +import { MAX_SUPPORTED_CANVAS_SCHEMA_VERSION } from '@workbench/canvasSchemaVersion'; +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from '@workbench/projects/documentSchemaVersion'; import { collectLiveAssetRefs, selectCoverImageName } from '@workbench/projects/projectAssets'; import type { InvkBoardSnapshot } from './board'; @@ -27,6 +29,7 @@ import { INVK_IMAGES_PREFIX, INVK_MANIFEST_ENTRY, INVK_VIDEOS_PREFIX, + INVK_WORKSPACE_ENTRY, InvkFormatError, } from './format'; import { type InvkManifest, parseInvkManifest } from './manifest'; @@ -47,22 +50,25 @@ export interface InvkArchiveContents { images: Map; fonts?: EmbeddedFont[]; manifest: InvkManifest; + /** `project.json`: the project content, or a whole document (v2, or a v3 written below the split). */ projectDocument: Record; /** Bundled video bytes, keyed by the video name the exporting server used. */ videos: Map; + /** `workspace.json`: the exporter's workspace, or `null` when the archive carries none. */ + workspace: Record | null; } -const parseDocumentEntry = (bytes: Uint8Array): Record => { +const parseDocumentEntry = (bytes: Uint8Array, entryName: string): Record => { let parsed: unknown; try { parsed = JSON.parse(readEntryText(bytes)); } catch { - throw new InvkFormatError('damaged', `${INVK_DOCUMENT_ENTRY} is not valid JSON.`); + throw new InvkFormatError('damaged', `${entryName} is not valid JSON.`); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new InvkFormatError('damaged', `${INVK_DOCUMENT_ENTRY} is not a project document.`); + throw new InvkFormatError('damaged', `${entryName} is not a project document.`); } return parsed as Record; @@ -116,6 +122,20 @@ export const readInvkArchive = async (file: File): Promise } const manifest = parseInvkManifest(manifestData); + + // Refused before any entry is read: a newer build may shape what it writes in ways this one would call damage. + if ((manifest.minimumCanvasSchemaVersion ?? 1) > MAX_SUPPORTED_CANVAS_SCHEMA_VERSION) { + throw new InvkFormatError( + 'unsupported-version', + `Project requires canvas schema ${manifest.minimumCanvasSchemaVersion}.` + ); + } + if (manifest.version === 3 && manifest.documentSchemaVersion > PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION) { + throw new InvkFormatError( + 'unsupported-version', + `Project requires document schema ${manifest.documentSchemaVersion}.` + ); + } const documentEntry = entries.get(INVK_DOCUMENT_ENTRY); if (!documentEntry) { @@ -145,8 +165,15 @@ export const readInvkArchive = async (file: File): Promise } const coverBytes = manifest.cover === undefined ? undefined : entries.get(manifest.cover); - const projectDocument = parseDocumentEntry(documentEntry); - const fonts = await readEmbeddedFonts(manifest.fonts ?? [], projectDocument, entries); + const projectDocument = parseDocumentEntry(documentEntry, INVK_DOCUMENT_ENTRY); + // Only a v3 archive's content has a workspace beside it; an entry in an older one names nothing. + const workspaceEntry = manifest.version === 3 ? entries.get(INVK_WORKSPACE_ENTRY) : undefined; + const workspace = workspaceEntry === undefined ? null : parseDocumentEntry(workspaceEntry, INVK_WORKSPACE_ENTRY); + const fonts = await readEmbeddedFonts( + manifest.fonts ?? [], + { canvas: [projectDocument.canvas, workspace?.canvas] }, + entries + ); return { boardSnapshot, @@ -156,6 +183,7 @@ export const readInvkArchive = async (file: File): Promise manifest, projectDocument, videos, + workspace, }; }; diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/manifest.test.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/manifest.test.ts index 75c4314e1c4..fe62b0136ab 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/manifest.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/manifest.test.ts @@ -22,10 +22,17 @@ const expectReason = (data: unknown, reason: string): void => { }; describe('parseInvkManifest', () => { - it('accepts a v2 manifest', () => { + it('accepts a v2 manifest, whose project.json is a whole document', () => { expect(parseInvkManifest(v2)).toEqual(v2); }); + it('requires a v3 manifest to name the document schema of its project.json', () => { + expect(parseInvkManifest({ ...v2, documentSchemaVersion: 4, version: 3 })).toMatchObject({ + documentSchemaVersion: 4, + }); + expectReason({ ...v2, version: 3 }, 'damaged'); + }); + it('keeps the optional cover and source project id', () => { expect( parseInvkManifest({ @@ -73,9 +80,21 @@ describe('parseInvkManifest', () => { describe('buildInvkManifest', () => { it('omits the optional keys rather than writing undefined into the JSON', () => { - const manifest = buildInvkManifest({ appVersion: '7.0', createdAt: v2.createdAt, name: 'My project' }); + const manifest = buildInvkManifest({ + appVersion: '7.0', + createdAt: v2.createdAt, + documentSchemaVersion: 4, + name: 'My project', + }); - expect(Object.keys(manifest).sort()).toEqual(['appVersion', 'contents', 'createdAt', 'name', 'version']); + expect(Object.keys(manifest).sort()).toEqual([ + 'appVersion', + 'contents', + 'createdAt', + 'documentSchemaVersion', + 'name', + 'version', + ]); }); it('round-trips through parse', () => { @@ -83,11 +102,13 @@ describe('buildInvkManifest', () => { appVersion: '7.0', cover: 'cover.webp', createdAt: v2.createdAt, + documentSchemaVersion: 4, minimumCanvasSchemaVersion: 3, name: 'My project', sourceProjectId: 'project-1', }); + expect(manifest).toMatchObject({ documentSchemaVersion: 4, version: 3 }); expect(parseInvkManifest(JSON.parse(JSON.stringify(manifest)))).toEqual(manifest); }); }); diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/manifest.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/manifest.ts index eed506910e2..35ce2652e94 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/manifest.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/manifest.ts @@ -4,8 +4,8 @@ import { zInvkFontDependency } from './fonts'; import { INVK_EXTENSION, INVK_VERSION, InvkFormatError } from './format'; /** - * The v2 ZIP contains manifest.json, project.json, optional board.json and cover, and images/ or - * videos/. Recognize v1 only to return a specific unsupported-version refusal. + * The ZIP contains manifest.json, project.json, optional workspace.json (v3), board.json and cover, and images/ + * or videos/. Recognize v1 only to return a specific unsupported-version refusal. */ const zManifestV1 = z.object({ @@ -31,15 +31,24 @@ const zManifestV2 = z.object({ version: z.literal(2), }); -const zManifest = z.discriminatedUnion('version', [zManifestV1, zManifestV2]); +const zManifestV3 = zManifestV2.extend({ + /** + * The document schema `project.json` is written in: 4 for project content (beside an optional `workspace.json`), 3 + * or lower for a whole document an exporter could not split. + */ + documentSchemaVersion: z.number().int().min(1), + version: z.literal(3), +}); + +const zManifest = z.discriminatedUnion('version', [zManifestV1, zManifestV2, zManifestV3]); /** Every version this app has ever written or can name. Anything else genuinely is from the future. */ -const KNOWN_VERSIONS: ReadonlySet = new Set([1, 2]); +const KNOWN_VERSIONS: ReadonlySet = new Set([1, 2, 3]); /** A manifest this app can read: the workbench project container. */ -export type InvkManifest = z.infer; +export type InvkManifest = z.infer | z.infer; -/** Accept v2; recognize v1 only for a named refusal. */ +/** Accept v2 and v3; recognize v1 only for a named refusal. */ export const parseInvkManifest = (data: unknown): InvkManifest => { const parsed = zManifest.safeParse(data); @@ -65,6 +74,7 @@ export const buildInvkManifest = (input: { appVersion: string; cover?: string; createdAt: string; + documentSchemaVersion: number; fonts?: InvkManifest['fonts']; minimumCanvasSchemaVersion?: number; name: string; @@ -73,6 +83,7 @@ export const buildInvkManifest = (input: { appVersion: input.appVersion, contents: 'workbench-project', createdAt: input.createdAt, + documentSchemaVersion: input.documentSchemaVersion, name: input.name, version: INVK_VERSION, ...(input.fonts === undefined || input.fonts.length === 0 ? {} : { fonts: input.fonts }), diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/memberBoards.test.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/memberBoards.test.ts index d344b87e7d4..64e93fec5fe 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/memberBoards.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/memberBoards.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { InvkBoard } from './board'; -import { createStagingBoards, findInboxStagingBoardId, placeMemberBoards } from './memberBoards'; +import { createStagingBoards, placeMemberBoards } from './memberBoards'; import { createRestoredMediaLedger } from './restoreProjectMedia'; const item = { category: 'general' as const, kind: 'image' as const, name: 'a.png', starred: false }; @@ -17,14 +17,31 @@ describe('createStagingBoards', () => { const staged = await createStagingBoards([inbox, old, empty], 'Project', ledger, undefined, create); - expect(staged.map(({ stagingBoardId }) => stagingBoardId)).toEqual([ + expect(staged.boards.map(({ stagingBoardId }) => stagingBoardId)).toEqual([ 'staged:Project', 'staged:Old', 'staged:Empty', ]); - expect(staged.map(({ board }) => board)).toEqual([inbox, old, empty]); - expect(findInboxStagingBoardId(staged)).toBe('staged:Project'); - expect(findInboxStagingBoardId([])).toBeNull(); + expect(staged.boards.map(({ board }) => board)).toEqual([inbox, old, empty]); + expect(staged.inboxStagingBoardId).toBe('staged:Project'); + }); + + it('stages an empty inbox for a source without one, so the create always has a board to claim', async () => { + const ledger = createRestoredMediaLedger([]); + const create = vi.fn((name: string) => Promise.resolve(`staged:${name}`)); + + const none = await createStagingBoards([], 'Project', ledger, undefined, create); + const membersOnly = await createStagingBoards([old], 'Project', ledger, undefined, create); + + expect(none).toEqual({ + boards: [ + { board: { archived: false, isInbox: true, items: [], name: 'Project' }, stagingBoardId: 'staged:Project' }, + ], + inboxStagingBoardId: 'staged:Project', + }); + expect(membersOnly.boards.map(({ board }) => board.isInbox)).toEqual([true, false]); + expect(membersOnly.inboxStagingBoardId).toBe('staged:Project'); + expect(ledger.boardIds).toEqual(['staged:Project', 'staged:Project', 'staged:Old']); }); it('puts each staging board on the ledger as it is made, so a failed later create leaves none unowned', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/memberBoards.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/memberBoards.ts index 4651f411e37..3b10d0e38f6 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/memberBoards.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/memberBoards.ts @@ -8,7 +8,8 @@ import { createStagingBoard, getBoardProjectId, isRequestCancellation, placeBoar * A project's boards travel as a set, but only the inbox rides the project create: the server claims it in the same * transaction that writes the document. So every board is staged alike — a private Library board, made in source * order so creation order survives — media lands on it before the commit point, and the others are moved into the - * project once it exists. + * project once it exists. An inbox is always staged, even for a source that lists no boards, so the create knows the + * board id the creator's workspace opens on and carries both in one request. */ export interface StagedBoard { @@ -17,6 +18,12 @@ export interface StagedBoard { stagingBoardId: string; } +export interface StagedBoards { + boards: StagedBoard[]; + /** The staging board the project create claims as its inbox. */ + inboxStagingBoardId: string; +} + export interface MemberBoardPlacementDeps { getBoardProjectId?: typeof getBoardProjectId; /** After each board settles, placed or not. */ @@ -27,7 +34,8 @@ export interface MemberBoardPlacementDeps { /** * One staging board per board, in `boards` order, each on the ledger the moment it exists so a restore abandoned - * part-way can delete it. The inbox's is named after the project, the others after themselves. + * part-way can delete it. The inbox's is named after the project, the others after themselves. A source without an + * inbox (an archive with no board entries, say) gets an empty one staged first. */ export const createStagingBoards = async ( boards: readonly InvkBoard[], @@ -35,23 +43,22 @@ export const createStagingBoards = async ( ledger: RestoredMediaLedger, signal?: AbortSignal, create: typeof createStagingBoard = createStagingBoard -): Promise => { +): Promise => { const staged: StagedBoard[] = []; + const withInbox = boards.some((board) => board.isInbox) + ? boards + : [{ archived: false, isInbox: true, items: [], name: projectName }, ...boards]; - for (const board of boards) { + for (const board of withInbox) { const stagingBoardId = await create(board.isInbox ? projectName : board.name, signal); ledger.boardIds.push(stagingBoardId); staged.push({ board, stagingBoardId }); } - return staged; + return { boards: staged, inboxStagingBoardId: staged.find(({ board }) => board.isInbox)!.stagingBoardId }; }; -/** The staging board the project create claims as its inbox; `null` when nothing was staged. */ -export const findInboxStagingBoardId = (staged: readonly StagedBoard[]): string | null => - staged.find(({ board }) => board.isInbox)?.stagingBoardId ?? null; - /** * Move every board but the inbox into the created project, archived as the source had it. Each settles on its own, * because a project with most of its boards beats none: a board that could not move is reported, and stays in the diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/transferCreation.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/transferCreation.ts new file mode 100644 index 00000000000..e17e21f4c40 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/transferCreation.ts @@ -0,0 +1,32 @@ +import type { ProjectCreateRequest } from '@workbench/projects/api'; +import type { ProjectDocumentV3 } from '@workbench/projects/projectDocument'; + +import { applyAuthoritativeProjectBoard } from '@workbench/projects/projectDocument'; +import { toProjectCreation } from '@workbench/projects/projectHalves'; + +/** + * What an import or a duplication creates, in one request: the content beside the creator's workspace, so the project + * never exists without it, or a whole document with the workspace inside (`toProjectCreation`). The workspace opens on + * the inbox the create claims, which is why the inbox is staged before the create: its id must be known to go in. + */ +export const toTransferredProjectCreation = ( + document: ProjectDocumentV3, + inboxStagingBoardId: string +): Pick => { + const creation = toProjectCreation(document); + const openingOnInbox = (half: Record): Record => + applyAuthoritativeProjectBoard(half, inboxStagingBoardId, { selectBoard: true }); + + return creation.workspace + ? { + board_id: inboxStagingBoardId, + data: creation.data as unknown as Record, + minimum_document_schema_version: creation.documentSchemaVersion, + workspace: openingOnInbox(creation.workspace as unknown as Record), + } + : { + board_id: inboxStagingBoardId, + data: openingOnInbox(creation.data as unknown as Record), + minimum_document_schema_version: creation.documentSchemaVersion, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/projects/library.test.ts b/invokeai/frontend/webv2/src/workbench/projects/library.test.ts index 1291c60cea4..1789acc7c9a 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/library.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/library.test.ts @@ -152,6 +152,45 @@ describe('refreshProjectLibrary', () => { expect(api.getProject).not.toHaveBeenCalled(); }); + it('dates each project by when the caller last worked on it: the later of its content and their workspace', async () => { + api.listProjects.mockResolvedValue([ + // Content saved last week, but the caller's own workspace saved since: worked on recently. + { + ...summaryDto('workspace', 'Workspace', '2026-06-03 10:00:00.000'), + workspace_updated_at: '2026-06-11 09:00:00.000', + }, + { + ...summaryDto('content', 'Content', '2026-06-10 10:00:00.000'), + workspace_updated_at: '2026-06-01 10:00:00.000', + }, + { ...summaryDto('none', 'None', '2026-06-09 10:00:00.000'), workspace_updated_at: null }, + ]); + + await library.refreshProjectLibrary(); + + const { summaries } = library.getProjectLibrary(); + expect(summaries.map((summary) => [summary.id, summary.updatedAt])).toEqual([ + ['workspace', '2026-06-11T09:00:00.000Z'], + ['content', '2026-06-10T10:00:00.000Z'], + ['none', '2026-06-09T10:00:00.000Z'], + ]); + }); + + it('identifies summaries that require a newer document reader without fetching the document', async () => { + api.listProjects.mockResolvedValue([ + { ...summaryDto('split', 'Split', '2026-06-10 10:00:00.000'), minimum_document_schema_version: 4 }, + { ...summaryDto('future', 'Future', '2026-06-10 10:00:00.000'), minimum_document_schema_version: 5 }, + ]); + + await library.refreshProjectLibrary(); + + const compatible = Object.fromEntries( + library.getProjectLibrary().summaries.map((summary) => [summary.id, library.isProjectSummaryCompatible(summary)]) + ); + expect(compatible).toEqual({ future: false, split: true }); + expect(api.getProject).not.toHaveBeenCalled(); + }); + it('keeps the previous summaries and reports the failure on error', async () => { api.listProjects.mockResolvedValue([summaryDto('kept', 'Kept', '2026-06-10 10:00:00.000')]); await library.refreshProjectLibrary(); diff --git a/invokeai/frontend/webv2/src/workbench/projects/library.ts b/invokeai/frontend/webv2/src/workbench/projects/library.ts index dddb384605e..8153f098913 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/library.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/library.ts @@ -21,6 +21,7 @@ import { listProjects, updateProject as apiUpdateProject, type ProjectRecordDTO, + type ProjectWithWorkspaceDTO, type ProjectSummaryDTO, } from './api'; import { @@ -31,6 +32,7 @@ import { recordProjectCover, subscribeProjectCovers, } from './covers'; +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; import { refreshOpenProjects } from './openProjects'; import { assertProjectFlushed } from './projectFlush'; import { pruneSessionProject } from './session'; @@ -43,7 +45,10 @@ export interface ProjectSummary { name: string; revision: number; minimumCanvasSchemaVersion: number; + /** Absent before the server reported it: a project saved whole. */ + minimumDocumentSchemaVersion?: number; createdAt: string; + /** The later of the content's save and the caller's own workspace save: when they last worked on it. */ updatedAt: string; /** The cover index is independent of document-free listings; absent entries yield no cover. */ coverUrl?: string; @@ -78,9 +83,13 @@ const toSummary = (dto: ProjectSummaryDTO): ProjectSummary => createdAt: normalizeServerTimestamp(dto.created_at), id: dto.project_id, minimumCanvasSchemaVersion: dto.minimum_canvas_schema_version, + minimumDocumentSchemaVersion: dto.minimum_document_schema_version, name: dto.name, revision: dto.revision, - updatedAt: normalizeServerTimestamp(dto.updated_at), + updatedAt: [dto.updated_at, dto.workspace_updated_at] + .filter((timestamp): timestamp is string => typeof timestamp === 'string') + .map(normalizeServerTimestamp) + .reduce((latest, timestamp) => (timestamp > latest ? timestamp : latest)), }); /** Recompute summaries when the independent cover index changes. */ @@ -177,7 +186,8 @@ export const upsertProjectSummary = ( }; export const isProjectSummaryCompatible = (summary: ProjectSummary): boolean => - isCanvasSchemaVersionSupported(summary.minimumCanvasSchemaVersion); + isCanvasSchemaVersionSupported(summary.minimumCanvasSchemaVersion) && + (summary.minimumDocumentSchemaVersion ?? 0) <= PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; /** Open projects mutate through their sync engine; closed projects use the HTTP API directly. */ @@ -272,7 +282,10 @@ export const renameLibraryProject = async (projectId: string, name: string): Pro }; /** Export/copy requires the paint barrier and an acknowledged flush before reading server bytes. */ -export const readAcknowledgedProject = async (projectId: string, owner: AccountScope): Promise => { +export const readAcknowledgedProject = async ( + projectId: string, + owner: AccountScope +): Promise => { const openProject = getOpenProject(projectId); if (openProject) { diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectDocument.ts b/invokeai/frontend/webv2/src/workbench/projects/projectDocument.ts index 205be63f1c0..f631947ca4b 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectDocument.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectDocument.ts @@ -4,9 +4,10 @@ import type { ProjectWorkflowCollection } from '@workbench/projectWorkflows'; import { stripInfiniteWindowAnchor, stripSessionScopedGallerySearch } from '@features/gallery/contracts'; import { migrateProjectGraphToCollection, normalizeProjectWorkflowCollection } from '@workbench/projectWorkflows'; +import { PROJECT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; + /** Keep document codecs reducer-free; load rehydration lazily. */ -export const PROJECT_DOCUMENT_SCHEMA_VERSION = 3; export const PROJECT_DOCUMENT_MAX_BYTES = 32 * 1024 * 1024; export type ProjectDocumentV3 = Omit< diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectFile.test.ts b/invokeai/frontend/webv2/src/workbench/projects/projectFile.test.ts index f5217fc4bb2..861d0095e04 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectFile.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectFile.test.ts @@ -130,7 +130,10 @@ const projectWithRestorableAssets = (includeVideo = true) => { stacks: stacksFrom([rasterImageLayer('restored-image', 'archive-image.png')]), }, }, - ...(includeVideo ? { futureVideoInput: { video_name: 'archive-video.mp4' } } : {}), + // A video only a workflow input would name: the document carries it wherever the split puts the field. + ...(includeVideo + ? { invocation: { ...project.invocation, futureVideoInput: { video_name: 'archive-video.mp4' } } } + : {}), }; }; @@ -140,7 +143,7 @@ const capturedArchive = (): File => { return new File([blob], fileName); }; -/** The server answers with the board it claimed, or with the one it created for a boardless create. */ +/** The server answers with the board it claimed, and with the workspace the create stored beside the content. */ const acceptCreate = (): void => { api.createProjectSettled.mockImplementation( (request: { @@ -149,6 +152,7 @@ const acceptCreate = (): void => { minimum_canvas_schema_version?: number; name: string; project_id?: string; + workspace?: Record; }) => Promise.resolve({ board_id: request.board_id ?? 'server-created-board', @@ -159,10 +163,17 @@ const acceptCreate = (): void => { project_id: request.project_id ?? '', revision: 1, updated_at: '2026-06-10 10:00:00.000', + workspace: request.workspace + ? { data: request.workspace, revision: 1, updated_at: '2026-06-10 10:00:00.000' } + : null, }) ); }; +/** The workspace the create carried, as the request names it. */ +const createdWorkspace = (call = 0): Record => + (api.createProjectSettled.mock.calls[call]![0] as { workspace: Record }).workspace; + beforeEach(async () => { vi.resetModules(); vi.resetAllMocks(); @@ -269,16 +280,22 @@ describe('exportLibraryProject', () => { const entries = await readArchive(new Uint8Array(await blob.arrayBuffer())); const exported = JSON.parse(readEntryText(entries.get('project.json')!)) as Record; - expect(exported.documentSchemaVersion).toBe(3); + expect(exported.documentSchemaVersion).toBe(4); expect(exported).not.toHaveProperty('events'); expect(exported).not.toHaveProperty('graphHistory'); expect(exported).not.toHaveProperty('queue'); }); it('exports a future-schema server record verbatim for recovery', async () => { - const document = { documentSchemaVersion: 4, futureField: { value: 42 }, id: 'future', name: 'Future' }; + const document = { documentSchemaVersion: 5, futureField: { value: 42 }, id: 'future', name: 'Future' }; - api.getProject.mockResolvedValue({ data: document, name: 'Future', project_id: 'p1', revision: 3 }); + api.getProject.mockResolvedValue({ + data: document, + name: 'Future', + project_id: 'p1', + revision: 3, + workspace: null, + }); await projectFile.exportLibraryProject('p1'); @@ -606,7 +623,10 @@ describe('importing a project board', () => { acceptCreate(); const { record } = await projectFile.importProjectFile(await exportedBoardArchive()); - const values = Object.values(record.data.widgetInstances as Record) + // The selection is the importer's workspace. + const values = Object.values( + record.workspace!.data.widgetInstances as Record + ) .map((instance) => instance.state?.values as { projectBoardId?: string; selectedBoardId?: string } | undefined) .filter((instanceValues) => instanceValues?.projectBoardId !== undefined); @@ -836,14 +856,19 @@ describe('importProjectFile', () => { await projectFile.importProjectFile(new File([blob], 'legacy.invk')); - const createRequest = api.createProjectSettled.mock.calls[0]![0] as { data: Record }; - const invocation = createRequest.data.invocation as { sourceId: string }; + const createRequest = api.createProjectSettled.mock.calls[0]![0] as { + data: Record; + minimum_document_schema_version: number; + }; + const workspace = createdWorkspace() as { invocation: { sourceId: string } }; const canvas = createRequest.data.canvas as { document: { stacks: { raster: Array<{ source: { image: { imageName: string } } }> } }; }; - expect(invocation.sourceId).toBe('workflow'); - expect(createRequest.data.futureDocumentKey).toEqual({ survives: true }); + // Created split, as the editor would save it: fields of neither half do not survive. + expect(createRequest.minimum_document_schema_version).toBe(4); + expect(createRequest.data).not.toHaveProperty('futureDocumentKey'); + expect(workspace.invocation.sourceId).toBe('workflow'); expect(canvas.document.stacks.raster[0]?.source.image.imageName).toBe('server-legacy.png'); }); @@ -880,8 +905,10 @@ describe('importProjectFile', () => { expect(record.name).toBe('Legacy JSON project'); expect(request.project_id).not.toBe('legacy-project-id'); expect(request.data.id).toBe(request.project_id); - expect(request.data.invocation).toMatchObject({ sourceId: 'workflow' }); - expect(request.data.futureDocumentKey).toEqual({ survives: true }); + expect(createdWorkspace()).toMatchObject({ invocation: { sourceId: 'workflow' } }); + // Even a file without boards stages an inbox first: the create claims it and the workspace opens on it. + expect(transport.createStagingBoard).toHaveBeenCalledExactlyOnceWith('Legacy JSON project', expect.anything()); + expect(api.createProjectSettled.mock.calls[0]![0]).toMatchObject({ board_id: 'staging-board' }); expect(transport.findExistingImageNames).not.toHaveBeenCalled(); expect(transport.findExistingVideoNames).not.toHaveBeenCalled(); expect(transport.uploadArchiveImage).not.toHaveBeenCalled(); @@ -1024,6 +1051,166 @@ describe('importProjectFile', () => { }); }); +describe('the exporter’s workspace in a project file', () => { + const entriesOf = async (file: File) => { + const { readArchive } = await import('./invk/archive'); + + return readArchive(new Uint8Array(await file.arrayBuffer())); + }; + const jsonEntry = async (file: File, entry: string) => { + const { readEntryText } = await import('./invk/archive'); + const bytes = (await entriesOf(file)).get(entry); + + return bytes === undefined ? undefined : (JSON.parse(readEntryText(bytes)) as Record); + }; + const prompted = { ...createDraftProject([]), promptHistory: [{ negativePrompt: null, positivePrompt: 'mine' }] }; + + it('carries it to the importer as their own, or leaves the importer a new project’s when left out', async () => { + acceptCreate(); + await projectFile.exportOpenProject(prompted); + const included = capturedArchive(); + await projectFile.exportOpenProject(prompted, { includeWorkspace: false }); + const leftOut = capturedArchive(); + + expect(await jsonEntry(included, 'manifest.json')).toMatchObject({ documentSchemaVersion: 4, version: 3 }); + expect(await jsonEntry(included, 'workspace.json')).toMatchObject({ promptHistory: prompted.promptHistory }); + expect(await jsonEntry(leftOut, 'workspace.json')).toBeUndefined(); + expect(JSON.stringify(await jsonEntry(leftOut, 'project.json'))).not.toContain('mine'); + + await projectFile.importProjectFile(included); + await projectFile.importProjectFile(leftOut); + + expect(api.createProjectSettled.mock.calls.map(([request]) => request.minimum_document_schema_version)).toEqual([ + 4, 4, + ]); + expect(createdWorkspace(0).promptHistory).toEqual(prompted.promptHistory); + expect(createdWorkspace(1).promptHistory).toEqual(createDraftProject([]).promptHistory); + }); + + it('exports a closed project with the caller’s stored workspace, and none when they have not saved one', async () => { + const { serializeProjectDocumentV3 } = await import('./projectDocument'); + const { splitProjectDocument } = await import('./projectHalves'); + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(prompted)); + const record = (stored: boolean) => ({ + data: content, + minimum_document_schema_version: 4, + name: 'Closed', + project_id: 'p1', + revision: 3, + workspace: stored ? { data: workspace, revision: 2, updated_at: '' } : null, + }); + + api.getProject + .mockResolvedValueOnce(record(true)) + .mockResolvedValueOnce(record(false)) + .mockResolvedValueOnce(record(true)); + await projectFile.exportLibraryProject('p1'); + const stored = capturedArchive(); + await projectFile.exportLibraryProject('p1'); + const none = capturedArchive(); + await projectFile.exportLibraryProject('p1', { includeWorkspace: false }); + const leftOut = capturedArchive(); + + expect(await jsonEntry(stored, 'workspace.json')).toMatchObject({ promptHistory: prompted.promptHistory }); + expect(await jsonEntry(none, 'workspace.json')).toBeUndefined(); + expect(await jsonEntry(leftOut, 'workspace.json')).toBeUndefined(); + }); + + it('keeps a project it cannot read recoverable, but never with a workspace it was asked to leave out', async () => { + const workspace = { data: { promptHistory: [{ positivePrompt: 'mine' }] }, revision: 2, updated_at: '' }; + const newer = { data: { documentSchemaVersion: 5, id: 'p1', name: 'Newer' }, name: 'Newer', project_id: 'p1' }; + const whole = { + data: { documentSchemaVersion: 3, id: 'p1', name: 'Unreadable' }, + name: 'Unreadable', + project_id: 'p1', + }; + const { ProjectWorkspaceInseparableError } = await import('./projectFileErrors'); + + api.getProject.mockResolvedValueOnce({ ...newer, revision: 3, workspace }); + await projectFile.exportLibraryProject('p1'); + const recovered = capturedArchive(); + api.getProject.mockResolvedValueOnce({ ...newer, revision: 3, workspace }); + await projectFile.exportLibraryProject('p1', { includeWorkspace: false }); + const recoveredWithout = capturedArchive(); + + expect(await jsonEntry(recovered, 'manifest.json')).toMatchObject({ documentSchemaVersion: 5 }); + expect(await jsonEntry(recovered, 'workspace.json')).toEqual(workspace.data); + expect(await jsonEntry(recoveredWithout, 'workspace.json')).toBeUndefined(); + api.getProject.mockResolvedValueOnce({ ...whole, revision: 3, workspace: null }); + await expect(projectFile.exportLibraryProject('p1', { includeWorkspace: false })).rejects.toBeInstanceOf( + ProjectWorkspaceInseparableError + ); + }); + + it('creates the content and the importer’s workspace in one request, which the record then carries', async () => { + acceptCreate(); + await projectFile.exportOpenProject(prompted); + + const outcome = await projectFile.importProjectFile(capturedArchive()); + + expect(api.createProjectSettled).toHaveBeenCalledOnce(); + expect(api.createProjectSettled.mock.calls[0]![0]).toMatchObject({ + board_id: 'staging-board', + minimum_document_schema_version: 4, + workspace: { documentSchemaVersion: 4, promptHistory: prompted.promptHistory }, + }); + expect(outcome.record.workspace).toMatchObject({ data: createdWorkspace(), revision: 1 }); + }); + + it('deletes the staged inbox of a file without boards when the create provably did not happen', async () => { + await projectFile.exportOpenProject(prompted); + api.createProjectSettled.mockRejectedValueOnce(new ProjectCreateAbsentError(new Error('refused'))); + + await expect(projectFile.importProjectFile(capturedArchive())).rejects.toBeInstanceOf(ProjectCreateAbsentError); + + expect(transport.deleteStagingBoard).toHaveBeenCalledExactlyOnceWith('staging-board', expect.anything()); + }); + + it('refuses a file whose content a newer document schema wrote, before any mutation', async () => { + const { binaryEntry, textEntry, writeArchive } = await import('./invk/archive'); + await projectFile.exportOpenProject(prompted); + const entries = await entriesOf(capturedArchive()); + const manifest = await jsonEntry(capturedArchive(), 'manifest.json'); + const rewritten = new Map([...entries].map(([path, bytes]) => [path, binaryEntry(bytes)])); + rewritten.set('manifest.json', textEntry(JSON.stringify({ ...manifest, documentSchemaVersion: 5 }))); + + await expect( + projectFile.importProjectFile(new File([await writeArchive(rewritten)], 'newer.invk')) + ).rejects.toMatchObject({ message: 'Project requires document schema 5.', reason: 'unsupported-version' }); + expect(transport.createStagingBoard).not.toHaveBeenCalled(); + expect(api.createProjectSettled).not.toHaveBeenCalled(); + }); + + it('imports a project whose workflow repeats an item id whole, with its workspace inside', async () => { + const [entry, ...rest] = prompted.workflows.entries; + const node = { data: { label: '', notes: '' }, id: 'node-1', position: { x: 0, y: 0 }, type: 'notes' }; + const repeating = { + ...prompted, + workflows: { + ...prompted.workflows, + entries: [{ ...entry!, document: { ...entry!.document, nodes: [node, node] } }, ...rest], + }, + } as unknown as typeof prompted; + + acceptCreate(); + await projectFile.exportOpenProject(repeating); + const { record } = await projectFile.importProjectFile(capturedArchive()); + + expect(api.createProjectSettled.mock.calls[0]![0]).toMatchObject({ + data: { documentSchemaVersion: 3, promptHistory: prompted.promptHistory }, + minimum_document_schema_version: 3, + }); + expect(api.createProjectSettled.mock.calls[0]![0]).not.toHaveProperty('workspace'); + expect(record.workspace).toBeNull(); + // The gallery opens on the staged inbox the create claimed, set inside the whole document it sent. + const values = Object.values(record.data.widgetInstances as Record) + .map((instance) => instance.state?.values as { selectedBoardId?: string } | undefined) + .filter((instanceValues) => instanceValues?.selectedBoardId !== undefined); + expect(values[0]).toMatchObject({ selectedBoardId: 'staging-board' }); + expect(record.board_id).toBe('staging-board'); + }); +}); + describe('embedded font project imports', () => { const fontProject = async () => { const { sha256Hex } = await import('@platform/browser/sha256'); diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectFile.ts b/invokeai/frontend/webv2/src/workbench/projects/projectFile.ts index 5eb29714b1f..399053698a6 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectFile.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectFile.ts @@ -8,16 +8,25 @@ import { captureAccountScope, isAccountScopeCurrent, } from '@platform/state/accountLifecycle'; -import { getProjectCanvasSchemaRequirement, MAX_SUPPORTED_CANVAS_SCHEMA_VERSION } from '@workbench/canvasSchemaVersion'; +import { getProjectCanvasSchemaRequirement } from '@workbench/canvasSchemaVersion'; import type { ProjectTransferIssues } from './invk/transfer'; +import type { ProjectDocumentV3 } from './projectDocument'; -import { createProjectSettled, getProjectBoardSnapshot, invalidateBoardLists, type ProjectRecordDTO } from './api'; +import { + createProjectSettled, + getProjectBoardSnapshot, + invalidateBoardLists, + type ProjectWithWorkspaceDTO, +} from './api'; import { recordProjectCover } from './covers'; +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; import { createProjectId } from './ids'; -import { INVK_EXTENSION, InvkFormatError, toInvkFormatReason } from './invk/format'; +import { INVK_EXTENSION, InvkFormatError, toInvkRefusalError } from './invk/format'; import { readAcknowledgedProject, upsertProjectSummary } from './library'; import { remapAssetRefs, stripInstallationState } from './projectAssets'; +import { ProjectWorkspaceInseparableError } from './projectFileErrors'; +import { splitProjectDocument } from './projectHalves'; import { getOpenProject } from './syncStore'; export const LEGACY_PROJECT_FILE_EXTENSION = '.invokeproject.json'; @@ -68,16 +77,25 @@ export interface ProjectFileOptions { onProgress?: (progress: ProjectFileProgress) => void; owner?: AccountScope; includeFonts?: boolean; + /** Export only: whether the file carries the exporter's workspace. On unless turned off. */ + includeWorkspace?: boolean; skipEmbeddedFonts?: boolean; } +/** What an archive's `project.json` and `workspace.json` carry, and the document schema the first is written in. */ +interface ArchivedProject { + content: Record; + documentSchemaVersion: number; + workspace: Record | null; +} + export interface ProjectExportOutcome extends ProjectTransferIssues { /** The name the archive was downloaded under. */ fileName: string; } export interface ProjectImportOutcome extends ProjectTransferIssues { - record: ProjectRecordDTO; + record: ProjectWithWorkspaceDTO; } const readProjectDocument = async (file: File) => { @@ -102,10 +120,42 @@ const readProjectDocument = async (file: File) => { return { contents: await readInvkArchive(file), format: 'invk' as const }; }; +/** A whole document as an archive carries it: its content, and the exporter's workspace unless it stays out. */ +const toArchivedProject = (document: ProjectDocumentV3, includeWorkspace: boolean): ArchivedProject => { + const halves = splitProjectDocument(document); + + return { + content: halves.content as unknown as Record, + documentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + workspace: includeWorkspace ? (halves.workspace as unknown as Record) : null, + }; +}; + +/** + * What the server holds of a project this build cannot read, verbatim, so it can still be recovered: content (schema 4 + * on, as a newer build may write it) beside the caller's workspace, or a whole document, whose workspace no build that + * cannot read it can take out. + */ +const toRecoveryArchive = (record: ProjectWithWorkspaceDTO, includeWorkspace: boolean): ArchivedProject => { + const { documentSchemaVersion } = record.data; + const schema = typeof documentSchemaVersion === 'number' ? documentSchemaVersion : 1; + const isSplit = schema >= PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + + if (!isSplit && !includeWorkspace) { + throw new ProjectWorkspaceInseparableError(); + } + + return { + content: record.data, + documentSchemaVersion: schema, + workspace: isSplit && includeWorkspace ? (record.workspace?.data ?? null) : null, + }; +}; + const exportProjectDocument = async ( name: string, projectId: string, - projectDocument: Record, + archived: ArchivedProject, minimumCanvasSchemaVersion: number, options: Required> & ProjectFileOptions ): Promise => { @@ -127,10 +177,12 @@ const exportProjectDocument = async ( items: board.items, name: board.name, })), + content: archived.content, createdAt: new Date().toISOString(), + documentSchemaVersion: archived.documentSchemaVersion, minimumCanvasSchemaVersion, name, - projectDocument, + workspace: archived.workspace, includeFonts: options.includeFonts ?? false, }); @@ -160,14 +212,22 @@ export const exportLibraryProject = async ( assertAccountScopeCurrent(owner); - const [{ deserializeProjectDocument }, { serializeProjectDocumentV3 }] = await Promise.all([ + const [{ deserializeProjectRecord, isSplitProjectRecord }, { serializeProjectDocumentV3 }] = await Promise.all([ import('./projectHydration'), import('./projectDocument'), ]); - const loaded = deserializeProjectDocument(record.data); - const document = loaded.status === 'loaded' ? serializeProjectDocumentV3(loaded.project) : record.data; - - return exportProjectDocument(record.name, record.project_id, document, record.minimum_canvas_schema_version, { + const loaded = deserializeProjectRecord(record); + const includeWorkspace = options.includeWorkspace ?? true; + const archived = + loaded.status === 'loaded' + ? // Of a split project the caller has never worked in, the workspace is defaults: nothing of theirs to carry. + toArchivedProject( + serializeProjectDocumentV3(loaded.project), + includeWorkspace && (!isSplitProjectRecord(record) || record.workspace !== null) + ) + : toRecoveryArchive(record, includeWorkspace); + + return exportProjectDocument(record.name, record.project_id, archived, record.minimum_canvas_schema_version, { ...options, owner, }); @@ -184,8 +244,8 @@ export const exportOpenProject = async ( await open?.flushPixels(); assertAccountScopeCurrent(owner); const project = open?.current() ?? requested; - const { serializeProjectDocument } = await import('./projectDocument'); - const document = serializeProjectDocument(project); + const { serializeProjectDocumentV3 } = await import('./projectDocument'); + const document = serializeProjectDocumentV3(project); assertAccountScopeCurrent(owner); @@ -196,26 +256,36 @@ export const exportOpenProject = async ( record?.minimum_canvas_schema_version ?? 1 ); - return exportProjectDocument(project.name, project.id, document, minimumCanvasSchemaVersion, { ...options, owner }); + return exportProjectDocument( + project.name, + project.id, + toArchivedProject(document, options.includeWorkspace ?? true), + minimumCanvasSchemaVersion, + { ...options, owner } + ); }; -/** Project creation commits remapped/staged media. Archives without board entries receive a new empty server board. */ +/** + * Project creation commits remapped/staged media. Every import stages an inbox board first, an empty one for a file + * without board entries, so the create claims it and carries the importer's workspace, open on it, in one request. + */ export const importProjectFile = async ( file: File, options: ProjectFileOptions = {} ): Promise => { const owner = options.owner ?? captureAccountScope(); const source = await readProjectDocument(file); - const projectDocument = source.format === 'invk' ? source.contents.projectDocument : source.projectDocument; - - if ( - source.format === 'invk' && - (source.contents.manifest.minimumCanvasSchemaVersion ?? 1) > MAX_SUPPORTED_CANVAS_SCHEMA_VERSION - ) { - throw new InvkFormatError( - 'unsupported-version', - `Project requires canvas schema ${source.contents.manifest.minimumCanvasSchemaVersion}.` - ); + const archivedDocument = source.format === 'invk' ? source.contents.projectDocument : source.projectDocument; + let projectDocument = archivedDocument; + // Project content (v3) is joined with the exporter's workspace, or without one with a new project's defaults. + if (archivedDocument.documentSchemaVersion === PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION) { + const { joinProjectHalves } = await import('./projectHydration'); + const joined = joinProjectHalves(archivedDocument, source.format === 'invk' ? source.contents.workspace : null); + + if (joined.status === 'malformed') { + throw new InvkFormatError('damaged', joined.reason); + } + projectDocument = joined.document; } assertAccountScopeCurrent(owner); @@ -235,7 +305,7 @@ export const importProjectFile = async ( const loaded = deserializeProjectDocument(candidate); if (loaded.status === 'refused') { - throw new InvkFormatError(toInvkFormatReason(loaded.refused), 'The project document was refused.'); + throw toInvkRefusalError(loaded.refused); } if (loaded.status !== 'loaded') { @@ -248,11 +318,15 @@ export const importProjectFile = async ( const { stripProjectWorkflowSources } = await import('@workbench/projectWorkflows'); const project = { ...loaded.project, workflows: stripProjectWorkflowSources(loaded.project.workflows) }; - const { applyAuthoritativeProjectBoard, serializeProjectDocument } = await import('./projectDocument'); + const { serializeProjectDocument } = await import('./projectDocument'); const canonicalDocument = serializeProjectDocument(project); const archive = source.format === 'invk' ? source.contents : null; - // Loaded only for an archive: a legacy JSON document restores nothing, so it has nothing to undo. - const restoreMedia = archive === null ? null : await import('./invk/restoreProjectMedia'); + // Every import stages at least its inbox board, which a failed create must undo, legacy JSON included. + const [restoreMedia, memberBoards, { toTransferredProjectCreation }] = await Promise.all([ + import('./invk/restoreProjectMedia'), + import('./invk/memberBoards'), + import('./invk/transferCreation'), + ]); const fontTransfer = archive?.fonts?.length && !options.skipEmbeddedFonts ? await import('./invk/fonts') : null; const fontTransport = fontTransfer ? (await import('./invk/fontTransport')).createFontArchiveTransport() : null; const fontLedger = fontTransfer?.createRestoredFontLedger() ?? null; @@ -264,10 +338,9 @@ export const importProjectFile = async ( assertAccountScopeCurrent(owner); const archiveBoards = archive?.boardSnapshot?.boards ?? []; - const memberBoards = archiveBoards.length === 0 ? null : await import('./invk/memberBoards'); // Made before anything is staged: every staging board joins it as it is created, so a restore abandoned at any // point can delete them all. - const ledger = restoreMedia?.createRestoredMediaLedger([]) ?? null; + const ledger = restoreMedia.createRestoredMediaLedger([]); let didCreateProject = false; let didAttemptProjectCreate = false; @@ -286,16 +359,17 @@ export const importProjectFile = async ( } // One staging board per archive board; the inbox's rides the create, the rest are placed after. - const stagedBoards = - memberBoards === null || ledger === null - ? [] - : await memberBoards.createStagingBoards(archiveBoards, name, ledger, owner.signal); - const inboxStagingBoardId = memberBoards?.findInboxStagingBoardId(stagedBoards) ?? null; + const { boards: stagedBoards, inboxStagingBoardId } = await memberBoards.createStagingBoards( + archiveBoards, + name, + ledger, + owner.signal + ); assertAccountScopeCurrent(owner); const restored = - archive === null || ledger === null + archive === null ? null : await (async () => { const { restoreArchiveMedia } = await import('./invk/importProject'); @@ -324,14 +398,15 @@ export const importProjectFile = async ( getProjectCanvasSchemaRequirement(document), archive?.manifest.minimumCanvasSchemaVersion ?? 1 ); + // The content and the importer's workspace (the archive's, or a new project's), open on the staged inbox, in one + // request: the project never exists without it. A project created whole carries the workspace inside. didAttemptProjectCreate = true; const record = await createProjectSettled( { - data: document, + ...toTransferredProjectCreation(document as unknown as ProjectDocumentV3, inboxStagingBoardId), minimum_canvas_schema_version: minimumCanvasSchemaVersion, name, project_id: id, - ...(inboxStagingBoardId === null ? {} : { board_id: inboxStagingBoardId }), }, owner ); @@ -340,15 +415,12 @@ export const importProjectFile = async ( assertAccountScopeCurrent(owner); // The project exists; its other boards can now belong to it. Reported, never fatal, from here on. Lists fetched // while they were still staged, the create's own refresh among them, would keep showing them in the Library. - const boardIssues = - memberBoards === null - ? [] - : await memberBoards - .placeMemberBoards(stagedBoards, record.project_id, { - onProgress: (completed, total) => options.onProgress?.({ completed, phase: 'placing-boards', total }), - signal: owner.signal, - }) - .finally(() => invalidateBoardLists(owner)); + const boardIssues = await memberBoards + .placeMemberBoards(stagedBoards, record.project_id, { + onProgress: (completed, total) => options.onProgress?.({ completed, phase: 'placing-boards', total }), + signal: owner.signal, + }) + .finally(() => invalidateBoardLists(owner)); assertAccountScopeCurrent(owner); upsertProjectSummary( { @@ -368,32 +440,25 @@ export const importProjectFile = async ( boardIssues, boardItemIssues: restored?.boardItemIssues ?? [], documentReferenceIssues: restored?.documentReferenceIssues ?? [], - // Bind the initial selection to the authoritative returned board ID, not the requested ID. - record: { - ...record, - data: applyAuthoritativeProjectBoard(record.data, record.board_id, { selectBoard: true }), - }, + record, }; } catch (error) { if (fontTransfer && fontTransport && fontLedger && isAccountScopeCurrent(owner)) { const rollback = () => fontTransfer.rollbackRestoredFonts(fontLedger, fontTransport, owner.signal); if (!didAttemptProjectCreate) { await rollback(); - } else if (restoreMedia) { - await restoreMedia.rollbackUnlessProjectExists(error, didCreateProject, owner, rollback); - } - } - // Media-free legacy JSON imports do not load restore rollback. - if (ledger !== null && restoreMedia !== null) { - // The staging boards it deletes may already be listed: uploads to them announce themselves. - const rollback = () => - restoreMedia.rollbackRestoredMedia(ledger, { signal: owner.signal }).finally(() => invalidateBoardLists(owner)); - if (!didAttemptProjectCreate && isAccountScopeCurrent(owner)) { - await rollback(); } else { await restoreMedia.rollbackUnlessProjectExists(error, didCreateProject, owner, rollback); } } + // The staging boards it deletes may already be listed: uploads to them announce themselves. + const rollback = () => + restoreMedia.rollbackRestoredMedia(ledger, { signal: owner.signal }).finally(() => invalidateBoardLists(owner)); + if (!didAttemptProjectCreate && isAccountScopeCurrent(owner)) { + await rollback(); + } else { + await restoreMedia.rollbackUnlessProjectExists(error, didCreateProject, owner, rollback); + } throw error; } diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectFileErrors.ts b/invokeai/frontend/webv2/src/workbench/projects/projectFileErrors.ts index 3abbfb10204..f929fa0bb04 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectFileErrors.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectFileErrors.ts @@ -4,6 +4,17 @@ import { ProjectFlushError } from './projectFlush'; /** Localize failure reason and direction; flush errors are distinct from archive corruption. */ export type ProjectFileDirection = 'read' | 'write'; +/** + * An export asked to leave the workspace out of a project held whole that this build cannot read: only a document it + * can read can be split, and the verbatim recovery copy would carry the workspace. + */ +export class ProjectWorkspaceInseparableError extends Error { + constructor() { + super('This project cannot be read here, so its workspace cannot be left out of the file.'); + this.name = 'ProjectWorkspaceInseparableError'; + } +} + export const describeProjectFileError = ( error: unknown, t: (key: string) => string, @@ -19,6 +30,10 @@ export const describeProjectFileError = ( ); } + if (error instanceof ProjectWorkspaceInseparableError) { + return t('projects.file.workspaceInseparable'); + } + if (!(error instanceof InvkFormatError)) { return error instanceof Error ? error.message : undefined; } diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectFileToasts.test.ts b/invokeai/frontend/webv2/src/workbench/projects/projectFileToasts.test.ts index bcd95fccfb3..72c4d30f55e 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectFileToasts.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectFileToasts.test.ts @@ -188,6 +188,18 @@ describe('startProjectFileReport', () => { }); }); + it('explains why an unreadable project cannot be exported without the workspace', async () => { + const { ProjectWorkspaceInseparableError } = await import('./projectFileErrors'); + const report = toasts.startProjectFileReport(t, 'projects.exporting', 'write'); + + report.fail('projects.exportFailed', new ProjectWorkspaceInseparableError()); + + expect(toaster.update.mock.calls.at(-1)![1]).toMatchObject({ + description: 'projects.file.workspaceInseparable', + type: 'error', + }); + }); + it('translates a format error into its reason rather than its message', async () => { const { InvkFormatError } = await import('./invk/format'); const report = toasts.startProjectFileReport(t, 'projects.importing'); diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectHalves.test.ts b/invokeai/frontend/webv2/src/workbench/projects/projectHalves.test.ts new file mode 100644 index 00000000000..deedbe09fb3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/projectHalves.test.ts @@ -0,0 +1,203 @@ +import type { Project } from '@workbench/projectContracts'; + +import { addProjectWorkflow, createBlankWorkflowDocument, recordProjectWorkflowRun } from '@workbench/projectWorkflows'; +import { createInitialWorkbenchState } from '@workbench/workbenchState.testing'; +import { describe, expect, it } from 'vitest'; + +import type { ProjectWithWorkspaceDTO } from './api'; + +import { serializeProjectDocumentV3, serializeProjectDocumentV3Json } from './projectDocument'; +import { + findRepeatedWorkflowItem, + joinProjectDocument, + PROJECT_DOCUMENT_HALVES, + splitProjectDocument, +} from './projectHalves'; +import { joinProjectRecord } from './projectHydration'; +import { deserializeProjectDocument } from './syncedPersistence'; + +const RUN = { completedAt: '2026-10-10T00:00:02.000Z', imageName: 'run.png', submittedAt: '2026-10-10T00:00:01.000Z' }; + +/** A project whose workspace differs from a fresh one: a second, active workflow with a run, and a staged image. */ +const workedOnProject = (): Project => { + const project = createInitialWorkbenchState().projects[0]!; + const second = createBlankWorkflowDocument(); + const withSecond = recordProjectWorkflowRun(addProjectWorkflow(project, second), second.id, RUN); + return { + ...withSecond, + canvas: { + ...withSecond.canvas, + stagingArea: { ...withSecond.canvas.stagingArea, isVisible: false, selectedImageIndex: 2 }, + }, + }; +}; + +const defaultsOf = (project: Project) => () => { + const { documentSchemaVersion: _schema, ...workspace } = splitProjectDocument( + serializeProjectDocumentV3(project) + ).workspace; + return workspace; +}; + +const load = (document: Record): Project => { + const loaded = deserializeProjectDocument(document); + if (loaded.status !== 'loaded') { + throw new Error(`Expected the document to load, got ${loaded.status}.`); + } + return loaded.project; +}; + +const join = (halves: { content: object; workspace: object | null }, defaults = defaultsOf(workedOnProject())) => + joinProjectDocument( + halves.content as Record, + halves.workspace as Record | null, + defaults + ); + +describe('splitting a project into content and workspace', () => { + it('rejoins to the same whole document', () => { + const project = workedOnProject(); + const halves = splitProjectDocument(serializeProjectDocumentV3(project)); + + const joined = join({ content: JSON.parse(halves.contentJson), workspace: JSON.parse(halves.workspaceJson) }); + + expect(joined.status).toBe('migrated'); + const rejoined = load((joined as { document: Record }).document); + // Against the project as loading leaves it: the canonical form sync compares. + expect(serializeProjectDocumentV3Json(rejoined).documentJson).toBe( + serializeProjectDocumentV3Json(load(serializeProjectDocumentV3(project))).documentJson + ); + }); + + it('carries every field of the document in one half or the other', () => { + const project = workedOnProject(); + const geometry = { height: 300, width: 400, x: 10, y: 20 }; + const optional = { + ...project, + floatingWidgets: { gallery: { ...geometry, zIndex: 1 } }, + lastFloatingGeometry: { gallery: geometry }, + presetWorkingLayouts: [{ presetId: project.layout.presetId, layout: project.layout }], + } as unknown as Project; + const document = serializeProjectDocumentV3(optional); + const { content, workspace } = splitProjectDocument(document); + + for (const [field, half] of Object.entries(PROJECT_DOCUMENT_HALVES)) { + const holders = [field in content && 'content', field in workspace && 'workspace'].filter(Boolean); + expect(holders, field).toEqual(half === 'both' ? ['content', 'workspace'] : [half]); + } + expect(Object.keys(document).sort()).toEqual(Object.keys(PROJECT_DOCUMENT_HALVES).sort()); + }); + + it('keeps how one member works out of the shared content', () => { + const project = workedOnProject(); + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(project)); + + expect(Object.keys(content).sort()).toEqual(['canvas', 'documentSchemaVersion', 'id', 'name', 'workflows']); + expect('stagingArea' in content.canvas).toBe(false); + expect(content.canvas.document.selectedLayerId).toBeNull(); + expect(content.workflows.entries.some((entry) => 'lastRun' in entry)).toBe(false); + expect(Object.values(workspace.workflows.lastRuns)).toEqual([RUN]); + expect(workspace.canvas.stagingArea.selectedImageIndex).toBe(2); + }); + + it.each(['__proto__', 'constructor', 'toString'])('round-trips the last run of a workflow with id %s', (id) => { + const project = createInitialWorkbenchState().projects[0]!; + const workflow = { ...createBlankWorkflowDocument(), id }; + const ran = recordProjectWorkflowRun(addProjectWorkflow(project, workflow), id, RUN); + const halves = splitProjectDocument(serializeProjectDocumentV3(ran)); + + const joined = join({ content: JSON.parse(halves.contentJson), workspace: JSON.parse(halves.workspaceJson) }); + + expect(Object.hasOwn(halves.workspace.workflows.lastRuns, id)).toBe(true); + const rejoined = load((joined as { document: Record }).document); + expect(rejoined.workflows.entries.find((entry) => entry.document.id === id)?.lastRun).toEqual(RUN); + expect(rejoined.workflows.entries.filter((entry) => entry.lastRun)).toHaveLength(1); + }); + + it('changes only the half an edit belongs to', () => { + const project = workedOnProject(); + const before = splitProjectDocument(serializeProjectDocumentV3(project)); + + const relaid = splitProjectDocument( + serializeProjectDocumentV3({ ...project, layout: { ...project.layout, centerViewId: 'gallery' } }) + ); + const renamed = splitProjectDocument(serializeProjectDocumentV3({ ...project, name: 'Renamed' })); + + expect(relaid.contentJson).toBe(before.contentJson); + expect(relaid.workspaceJson).not.toBe(before.workspaceJson); + expect(renamed.workspaceJson).toBe(before.workspaceJson); + expect(renamed.contentJson).not.toBe(before.contentJson); + }); +}); + +describe('joining a project for a member', () => { + it('starts a member with no workspace on the defaults, on the first workflow', () => { + const project = workedOnProject(); + const { content } = splitProjectDocument(serializeProjectDocumentV3(project)); + + const joined = join({ content, workspace: null }); + + const loaded = load((joined as { document: Record }).document); + expect(loaded.workflows.activeWorkflowId).toBe(project.workflows.entries[0]!.document.id); + expect(loaded.workflows.entries.some((entry) => entry.lastRun)).toBe(false); + expect(loaded.name).toBe(project.name); + }); + + it('falls back to the first workflow when the open one was deleted meanwhile', () => { + const project = workedOnProject(); + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(project)); + + const joined = join({ + content, + workspace: { ...workspace, workflows: { activeWorkflowId: 'gone', lastRuns: {} } }, + }); + + expect(load((joined as { document: Record }).document).workflows.activeWorkflowId).toBe( + project.workflows.entries[0]!.document.id + ); + }); + + it('refuses a server’s content that repeats an item id within a workflow, which a lock could not tell apart', () => { + const project = workedOnProject(); + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(project)); + const [first, ...rest] = content.workflows.entries; + const node = { id: 'node-1', type: 'notes' }; + const repeated = { ...first!, document: { ...first!.document, nodes: [node, node] } }; + const elsewhere = rest.map((entry) => ({ ...entry, document: { ...entry.document, nodes: [node] } })); + const record = (entries: unknown[]) => + ({ + data: { ...content, workflows: { entries } }, + workspace: { data: workspace, revision: 1, updated_at: '' }, + }) as unknown as ProjectWithWorkspaceDTO; + + expect(joinProjectRecord(record([repeated, ...rest])).status).toBe('malformed'); + // An archive's joins: importing creates it whole. + expect(join({ content: { ...content, workflows: { entries: [repeated, ...rest] } }, workspace }).status).toBe( + 'migrated' + ); + // Workflow duplication keeps node ids, so two workflows may share one. + expect( + joinProjectRecord(record([{ ...first!, document: { ...first!.document, nodes: [node] } }, ...elsewhere])).status + ).toBe('migrated'); + }); + + it('finds repeated edge and form element ids too', () => { + const { content } = splitProjectDocument(serializeProjectDocumentV3(workedOnProject())); + const [first, ...rest] = content.workflows.entries; + const edge = { id: 'edge-1', source: 'a', sourceHandle: 'value', target: 'b', targetHandle: 'value' }; + const element = { data: { children: [], layout: 'column' }, id: 'container-1', type: 'container' }; + const repeating = (document: object) => [{ ...first!, document: { ...first!.document, ...document } }, ...rest]; + + expect(findRepeatedWorkflowItem(repeating({ edges: [edge, edge] }))).toBe(first!.document.id); + expect( + findRepeatedWorkflowItem(repeating({ form: { elements: { a: element, b: element }, rootElementId: 'a' } })) + ).toBe(first!.document.id); + expect(findRepeatedWorkflowItem(content.workflows.entries)).toBeNull(); + }); + + it('refuses a workspace of another document schema', () => { + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(workedOnProject())); + + expect(join({ content, workspace: { ...workspace, documentSchemaVersion: 5 } }).status).toBe('malformed'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectHalves.ts b/invokeai/frontend/webv2/src/workbench/projects/projectHalves.ts new file mode 100644 index 00000000000..e05c904cfda --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/projectHalves.ts @@ -0,0 +1,237 @@ +import type { ProjectDocumentMigration, ProjectDocumentV3 } from './projectDocument'; + +import { PROJECT_DOCUMENT_SCHEMA_VERSION, PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; + +/* + * The project document as the server stores it from schema 4: the project **content** (what the project is, shared + * with every member) and each member's **workspace** (how they work on it). The browser keeps one whole document per + * project; the split happens only here, at the server boundary. + */ + +type CanvasState = ProjectDocumentV3['canvas']; +type ProjectWorkflowEntry = ProjectDocumentV3['workflows']['entries'][number]; +type ProjectWorkflowRunPreview = NonNullable; + +export interface ProjectContentV4 { + documentSchemaVersion: typeof PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + id: string; + name: string; + /** Without the staging area, and with no layer selected: both are the workspace's. */ + canvas: Omit; + /** Each entry without its last run, which belongs to whoever ran it. */ + workflows: { entries: Omit[] }; +} + +export interface ProjectWorkspaceV4 extends Pick< + ProjectDocumentV3, + | 'floatingWidgets' + | 'invocation' + | 'lastFloatingGeometry' + | 'layout' + | 'presetWorkingLayouts' + | 'promptHistory' + | 'settings' + | 'widgetGraphs' + | 'widgetInstances' + | 'widgetRegions' +> { + documentSchemaVersion: typeof PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + canvas: { selectedLayerId: string | null; stagingArea: CanvasState['stagingArea'] }; + workflows: { activeWorkflowId: string; lastRuns: Record }; +} + +/** + * Which half holds each field of the whole document (`both` for one split inside). Typed against the document, so a + * field added to it fails to compile until it is placed here, and in `splitProjectDocument`. + */ +export const PROJECT_DOCUMENT_HALVES = { + canvas: 'both', + documentSchemaVersion: 'both', + floatingWidgets: 'workspace', + id: 'content', + invocation: 'workspace', + lastFloatingGeometry: 'workspace', + layout: 'workspace', + name: 'content', + presetWorkingLayouts: 'workspace', + promptHistory: 'workspace', + settings: 'workspace', + widgetGraphs: 'workspace', + widgetInstances: 'workspace', + widgetRegions: 'workspace', + workflows: 'both', +} as const satisfies Record; + +export interface ProjectHalves { + content: ProjectContentV4; + /** Serialized on first read and kept: a caller that only sends the objects never pays for a large document's. */ + readonly contentJson: string; + workspace: ProjectWorkspaceV4; + readonly workspaceJson: string; +} + +/** Splits a whole document into the two halves the server saves apart. */ +export const splitProjectDocument = (document: ProjectDocumentV3): ProjectHalves => { + const { stagingArea, ...canvas } = document.canvas; + const lastRuns = new Map(); + const entries = document.workflows.entries.map(({ lastRun, ...entry }) => { + if (lastRun) { + lastRuns.set(entry.document.id, lastRun); + } + return entry; + }); + const content: ProjectContentV4 = { + canvas: { ...canvas, document: { ...canvas.document, selectedLayerId: null } }, + documentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + id: document.id, + name: document.name, + workflows: { entries }, + }; + const workspace: ProjectWorkspaceV4 = { + canvas: { selectedLayerId: document.canvas.document.selectedLayerId, stagingArea }, + documentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + ...(document.floatingWidgets ? { floatingWidgets: document.floatingWidgets } : {}), + invocation: document.invocation, + ...(document.lastFloatingGeometry ? { lastFloatingGeometry: document.lastFloatingGeometry } : {}), + layout: document.layout, + ...(document.presetWorkingLayouts ? { presetWorkingLayouts: document.presetWorkingLayouts } : {}), + promptHistory: document.promptHistory, + settings: document.settings, + widgetGraphs: document.widgetGraphs, + widgetInstances: document.widgetInstances, + widgetRegions: document.widgetRegions, + // Keyed by workflow id as own properties, whatever the id (`__proto__` through an object literal would not be). + workflows: { activeWorkflowId: document.workflows.activeWorkflowId, lastRuns: Object.fromEntries(lastRuns) }, + }; + + let contentJson: string | undefined; + let workspaceJson: string | undefined; + return { + content, + get contentJson() { + return (contentJson ??= JSON.stringify(content)); + }, + workspace, + get workspaceJson() { + return (workspaceJson ??= JSON.stringify(workspace)); + }, + }; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const repeats = (ids: unknown[]): boolean => new Set(ids).size !== ids.length; + +/** + * The item ids a lock or presence marker names must each name one item (SF-12): within a workflow, its nodes, its + * edges and its form elements. Layers are already unique across a canvas document's stacks, checked as it loads. + * Refused rather than repaired: a repair would have to guess which of two items an edit meant. + */ +export const findRepeatedWorkflowItem = (entries: unknown[]): string | null => { + for (const entry of entries) { + const document = isRecord(entry) && isRecord(entry.document) ? entry.document : null; + if (!document) { + continue; + } + const idsOf = (items: unknown): unknown[] => + Array.isArray(items) ? items.map((item) => (isRecord(item) ? item.id : undefined)) : []; + const form = isRecord(document.form) && isRecord(document.form.elements) ? document.form.elements : {}; + if (repeats(idsOf(document.nodes)) || repeats(idsOf(document.edges)) || repeats(idsOf(Object.values(form)))) { + return typeof document.id === 'string' ? document.id : ''; + } + } + return null; +}; + +/** + * Joins the two halves into the whole document the browser works on, which the ordinary migration and load path then + * reads. A project with no workspace for the caller yet (a 7.1 member, or one never saved) gets `defaults`, the + * workspace a new project starts with. Content that repeats an item id joins too: a server's must be refused + * (`joinProjectRecord`), but an archive's is created whole (`toProjectCreation`). + */ +export const joinProjectDocument = ( + content: Record, + workspace: Record | null, + defaults: () => Omit +): ProjectDocumentMigration => { + if (workspace !== null && workspace.documentSchemaVersion !== PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION) { + return { reason: 'The workspace has an unsupported document schema.', status: 'malformed' }; + } + const canvas = isRecord(content.canvas) ? content.canvas : null; + const workflows = isRecord(content.workflows) && Array.isArray(content.workflows.entries) ? content.workflows : null; + if (!canvas || !isRecord(canvas.document) || !workflows) { + return { reason: 'The project content is missing its canvas or workflows.', status: 'malformed' }; + } + const entries = workflows.entries as unknown[]; + + const fallback = workspace ? null : defaults(); + const own = (workspace ?? fallback) as Record; + const ownCanvas = isRecord(own.canvas) ? own.canvas : {}; + const ownWorkflows = isRecord(own.workflows) ? own.workflows : {}; + const lastRuns = isRecord(ownWorkflows.lastRuns) ? ownWorkflows.lastRuns : {}; + const entryIds = entries.map((entry) => (isRecord(entry) && isRecord(entry.document) ? entry.document.id : null)); + // Another member may have deleted the workflow this workspace had open. + const activeWorkflowId = entryIds.includes(ownWorkflows.activeWorkflowId as string) + ? ownWorkflows.activeWorkflowId + : entryIds[0]; + const { + canvas: _ownCanvas, + documentSchemaVersion: _workspaceSchema, + workflows: _ownWorkflows, + ...workspaceFields + } = own; + const { documentSchemaVersion: _contentSchema, ...contentFields } = content; + + return { + document: { + ...workspaceFields, + ...contentFields, + canvas: { + ...canvas, + document: { + ...canvas.document, + selectedLayerId: typeof ownCanvas.selectedLayerId === 'string' ? ownCanvas.selectedLayerId : null, + }, + ...(ownCanvas.stagingArea === undefined ? {} : { stagingArea: ownCanvas.stagingArea }), + }, + // The whole-document schema the browser works in; the migration boundary reads it as current. + documentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, + workflows: { + activeWorkflowId, + entries: entries.map((entry, index) => { + const id = entryIds[index] as string; + // Own properties only: a workflow id such as `constructor` must not read Object.prototype's. + const run = Object.hasOwn(lastRuns, id) ? lastRuns[id] : undefined; + return isRecord(entry) && isRecord(run) ? { ...entry, lastRun: run } : entry; + }), + }, + }, + status: 'migrated', + }; +}; + +/** + * How a document is first saved: its content, under the split floor, with the workspace to save beside it. A workflow + * that repeats an item id (only a build before the split could write one) would leave content no load accepts, so that + * project is created whole, as the server would already hold it, and keeps saving whole. + */ +export const toProjectCreation = ( + document: ProjectDocumentV3 +): + | { + data: ProjectContentV4; + documentSchemaVersion: typeof PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + workspace: ProjectWorkspaceV4; + } + | { data: ProjectDocumentV3; documentSchemaVersion: typeof PROJECT_DOCUMENT_SCHEMA_VERSION; workspace: null } => { + if (findRepeatedWorkflowItem(document.workflows.entries) !== null) { + return { data: document, documentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, workspace: null }; + } + const halves = splitProjectDocument(document); + return { + data: halves.content, + documentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + workspace: halves.workspace, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectHydration.ts b/invokeai/frontend/webv2/src/workbench/projects/projectHydration.ts index f8314cdbd1e..b574ff58d7a 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectHydration.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectHydration.ts @@ -1,24 +1,25 @@ import type { Project, ProjectLoadResult } from '@workbench/projectContracts'; -import { loadWorkbenchProject, withAuthoritativeProjectBoard } from '@workbench/workbenchState'; +import { createDraftProject, loadWorkbenchProject, withAuthoritativeProjectBoard } from '@workbench/workbenchState'; -import type { ProjectRecordDTO } from './api'; +import type { ProjectWithWorkspaceDTO } from './api'; +import { PROJECT_DOCUMENT_SCHEMA_VERSION, PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; import { applyAuthoritativeProjectBoard, migrateProjectDocument, - PROJECT_DOCUMENT_SCHEMA_VERSION, + serializeProjectDocumentV3, + type ProjectDocumentMigration, } from './projectDocument'; +import { findRepeatedWorkflowItem, joinProjectDocument, splitProjectDocument } from './projectHalves'; const toFutureProjectDocumentRefusal = ( data: Record, projectId = typeof data.id === 'string' ? data.id : '', - projectName = typeof data.name === 'string' ? data.name : '' + projectName = typeof data.name === 'string' ? data.name : '', + newestReadable = PROJECT_DOCUMENT_SCHEMA_VERSION ): ProjectLoadResult | null => { - if ( - !Number.isSafeInteger(data.documentSchemaVersion) || - (data.documentSchemaVersion as number) <= PROJECT_DOCUMENT_SCHEMA_VERSION - ) { + if (!Number.isSafeInteger(data.documentSchemaVersion) || (data.documentSchemaVersion as number) <= newestReadable) { return null; } @@ -68,13 +69,68 @@ export const deserializeProjectDocument = (data: Record): Proje return result.status === 'refused' ? { refused: { ...result.refused, raw: data }, status: 'refused' } : result; }; -export const deserializeProjectRecord = (record: ProjectRecordDTO): ProjectLoadResult => { - const futureDocument = toFutureProjectDocumentRefusal(record.data, record.project_id, record.name); +/** The workspace a project starts with, for a member who has not saved one in it. */ +const createDefaultProjectWorkspace = () => { + const { documentSchemaVersion: _schema, ...workspace } = splitProjectDocument( + serializeProjectDocumentV3(createDraftProject([])) + ).workspace; + return workspace; +}; + +/** Whether a record holds project content (document schema 4 on) rather than a document saved whole. */ +export const isSplitProjectRecord = (record: Pick): boolean => + record.data.documentSchemaVersion === PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + +/** Content joined with a workspace, or with the defaults a new project starts with when there is none. */ +export const joinProjectHalves = ( + content: Record, + workspace: Record | null +): ProjectDocumentMigration => joinProjectDocument(content, workspace, createDefaultProjectWorkspace); + +/** + * The whole document a read holds: itself when saved whole, else its content joined with the caller's workspace, or + * with the defaults a new project starts with before they have saved one. Split content that repeats an item id is + * refused: what a lock or presence marker names must name one item (SF-12). + */ +export const joinProjectRecord = (record: ProjectWithWorkspaceDTO): ProjectDocumentMigration => { + if (!isSplitProjectRecord(record)) { + return { document: record.data, status: 'migrated' }; + } + const joined = joinProjectHalves(record.data, record.workspace?.data ?? null); + // A join always yields a workflow collection. + const repeated = + joined.status === 'migrated' + ? findRepeatedWorkflowItem((joined.document.workflows as { entries: unknown[] }).entries) + : null; + return repeated === null ? joined : { reason: `Workflow ${repeated} repeats an item id.`, status: 'malformed' }; +}; + +export const deserializeProjectRecord = (record: ProjectWithWorkspaceDTO): ProjectLoadResult => { + const futureDocument = toFutureProjectDocumentRefusal( + record.data, + record.project_id, + record.name, + PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION + ); if (futureDocument) { return futureDocument; } + const joined = joinProjectRecord(record); + if (joined.status === 'malformed') { + return { + refused: { + projectId: record.project_id, + projectName: record.name, + raw: record.data, + refusal: { raw: record.data, reason: joined.reason, scope: 'project-document', status: 'malformed' }, + source: 'project-document', + }, + status: 'refused', + }; + } + const whole = joined.document; const result = deserializeProjectDocument( - applyAuthoritativeProjectBoard(record.data, record.board_id, { selectBoard: false }) + applyAuthoritativeProjectBoard(whole, record.board_id, { selectBoard: false }) ); if (result.status === 'loaded') { diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts b/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts index 8d3eb50608f..6615be0936c 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts @@ -1,6 +1,7 @@ import type { Project } from '@workbench/projectContracts'; import { addProjectWorkflow, getActiveProjectGraph, recordProjectWorkflowRun } from '@workbench/projectWorkflows'; +import { createDraftProject } from '@workbench/workbenchState'; import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState.testing'; import { describe, expect, it } from 'vitest'; @@ -9,6 +10,7 @@ import { serializeProjectDocumentV3, serializeProjectDocumentV3Json, } from './projectDocument'; +import { splitProjectDocument } from './projectHalves'; import { deserializeProjectDocument, deserializeProjectRecord, serializeProjectDocument } from './syncedPersistence'; const getProject = (overrides: Partial = {}): Project => { @@ -169,7 +171,7 @@ describe('project document serialization', () => { it('uses authoritative server identity and untouched bytes for a future document refusal', () => { const raw = { - documentSchemaVersion: 4, + documentSchemaVersion: 5, id: 'untrusted-id', name: 'Untrusted name', widgetInstances: { @@ -183,23 +185,55 @@ describe('project document serialization', () => { created_at: '2026-09-03T00:00:00.000Z', data: raw, minimum_canvas_schema_version: 4, + minimum_document_schema_version: 3, name: 'Authoritative name', project_id: 'authoritative-id', revision: 4, updated_at: '2026-09-03T00:00:00.000Z', + workspace_updated_at: null, + workspace: null, }) ).toEqual({ refused: { projectId: 'authoritative-id', projectName: 'Authoritative name', raw, - refusal: { raw, scope: 'project-document', status: 'unsupported-version', version: 4 }, + refusal: { raw, scope: 'project-document', status: 'unsupported-version', version: 5 }, source: 'project-document', }, status: 'refused', }); }); + it('opens a split project for a member with no workspace on the workspace a new project starts with', () => { + const fresh = createDraftProject([]); + const shared = { ...getProject(), name: 'Shared' }; + const { content } = splitProjectDocument(serializeProjectDocumentV3(shared)); + + const loaded = deserializeProjectRecord({ + board_id: 'board-1', + created_at: '2026-09-03T00:00:00.000Z', + data: content as unknown as Record, + minimum_canvas_schema_version: 3, + minimum_document_schema_version: 4, + name: 'Shared', + project_id: shared.id, + revision: 1, + updated_at: '2026-09-03T00:00:00.000Z', + workspace: null, + workspace_updated_at: null, + }); + + if (loaded.status !== 'loaded') { + throw new Error(`Expected the project to load, got ${loaded.status}.`); + } + expect(loaded.project.name).toBe('Shared'); + expect(loaded.project.layout).toEqual(fresh.layout); + expect(loaded.project.settings).toEqual(fresh.settings); + expect(Object.keys(loaded.project.widgetInstances).sort()).toEqual(Object.keys(fresh.widgetInstances).sort()); + expect(loaded.project.workflows.activeWorkflowId).toBe(shared.workflows.entries[0]!.document.id); + }); + it('migrates a schema-2 document into the first, active workflow with an unknown-revision source', () => { const project = getProject(); const legacyGraph = { ...getActiveProjectGraph(project), id: 'legacy-graph', libraryWorkflowId: 'lib-1' }; diff --git a/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts b/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts index 8366e97f716..e951df165f2 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts @@ -16,7 +16,13 @@ export interface ProjectSyncInfo { /** Present when the server requires a newer canvas schema than this client supports. */ schemaRefusal?: ProjectSchemaRefusal; /** Divergent local work awaits an explicit user decision. */ - conflict?: { detectedAt: string; kind: 'deleted' | 'revision'; serverRevision?: number }; + conflict?: { + detectedAt: string; + /** For a revision conflict: the caller's workspace diverged, not the project content. */ + half?: 'workspace'; + kind: 'deleted' | 'revision'; + serverRevision?: number; + }; } export interface ProjectSyncSnapshot { diff --git a/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.browser.test.ts b/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.browser.test.ts index 0651ad9d91b..2f4c1c0d54b 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.browser.test.ts @@ -78,10 +78,13 @@ beforeEach(() => { created_at: now, data: serializeProjectDocumentV3(project), minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, name: project.name, project_id: project.id, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }); void api.saveSession(stateWith([project]), 'tab-a', {}); }); diff --git a/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.scenarios.ts b/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.scenarios.ts index 1dfc834b077..626625d84e9 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.scenarios.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.scenarios.ts @@ -2,15 +2,20 @@ import type { Project, WorkbenchState } from '@workbench/projectContracts'; import { createUuid } from '@platform/browser/randomUuid'; import { accountLifecycle, captureAccountScope } from '@platform/state/accountLifecycle'; -import { createDraftProject, createInitialWorkbenchState } from '@workbench/workbenchState'; +import { + createDraftProject, + createInitialWorkbenchState, + withAuthoritativeProjectBoard, +} from '@workbench/workbenchState'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ProjectRecordDTO, ProjectSummaryDTO } from './api'; +import type { ProjectWithWorkspaceDTO, ProjectSummaryDTO } from './api'; import type { ProjectDraftStore, ProjectUnloadJournalEntry } from './draftStore'; import type { WorkbenchSessionBlob } from './session'; import { createDurableSyncedWorkbenchPersistence, type DurableProjectPersistenceApi } from './durableSyncedPersistence'; import { serializeProjectDocumentV3 } from './projectDocument'; +import { splitProjectDocument } from './projectHalves'; import { serializeSessionBlob } from './session'; import { getProjectSyncSnapshot } from './syncStore'; @@ -18,11 +23,14 @@ const now = '2026-10-05T00:00:00.000Z'; /** An in-memory project server; `offline` fails writes as an unreachable server does, and `gate` holds updates. */ export const createUnloadJournalServer = () => { - const records = new Map(); + const records = new Map(); let session: WorkbenchSessionBlob | null = null; const server = { - /** Applies an update but never answers it, as when the response is lost. */ + creates: 0, + /** Applies a create or an update but never answers it, as when the response is lost. */ + dropNextCreateResponse: false, dropNextUpdateResponse: false, + dropNextWorkspaceResponse: false, gate: null as Promise | null, offline: false, records, @@ -30,24 +38,46 @@ export const createUnloadJournalServer = () => { return session; }, updates: 0, + workspaceUpdates: 0, }; const write = (apply: () => T): Promise => server.offline ? Promise.reject(new TypeError('Failed to fetch')) : Promise.resolve(apply()); const notFound = () => Object.assign(new Error('not found'), { status: 404 }); const api: DurableProjectPersistenceApi = { + // As the settled create: a retry that finds its first attempt committed answers with that project, whichever + // halves it stored; an id another project holds is a conflict. createProject: (request) => write(() => { - const record: ProjectRecordDTO = { + server.creates += 1; + const existing = records.get(request.project_id!); + if (existing) { + if (JSON.stringify(existing.data) !== JSON.stringify(request.data)) { + throw Object.assign(new Error('conflict'), { status: 409 }); + } + return structuredClone(existing); + } + // The caller's first workspace is stored with the content, in the same step. + const workspace = request.workspace + ? { data: structuredClone(request.workspace), revision: 1, updated_at: now } + : null; + const record: ProjectWithWorkspaceDTO = { board_id: `board-${request.project_id!}`, created_at: now, data: structuredClone(request.data), minimum_canvas_schema_version: request.minimum_canvas_schema_version ?? 3, + minimum_document_schema_version: request.minimum_document_schema_version ?? 3, name: request.name, project_id: request.project_id!, revision: 1, updated_at: now, + workspace_updated_at: workspace ? now : null, + workspace, }; records.set(record.project_id, record); + if (server.dropNextCreateResponse) { + server.dropNextCreateResponse = false; + throw new TypeError('Failed to fetch'); + } return structuredClone(record); }), deleteProject: (projectId) => write(() => void records.delete(projectId)), @@ -61,6 +91,30 @@ export const createUnloadJournalServer = () => { [...records.values()].map(({ data: _data, ...summary }): ProjectSummaryDTO => structuredClone(summary)) ), loadSession: () => Promise.resolve(session && structuredClone(session)), + saveProjectWorkspace: async (projectId, request) => { + server.workspaceUpdates += 1; + await server.gate; + return write(() => { + const current = records.get(projectId); + if (!current) { + throw notFound(); + } + if ((current.workspace?.revision ?? null) !== request.expected_revision) { + throw Object.assign(new Error('conflict'), { status: 409 }); + } + const workspace = { + data: structuredClone(request.data), + revision: (current.workspace?.revision ?? 0) + 1, + updated_at: now, + }; + records.set(projectId, { ...current, workspace, workspace_updated_at: now }); + if (server.dropNextWorkspaceResponse) { + server.dropNextWorkspaceResponse = false; + throw new TypeError('Failed to fetch'); + } + return structuredClone(workspace); + }); + }, saveSession: (state, editorSessionId, draftEditorSessionIds) => write(() => { session = JSON.parse( @@ -80,18 +134,34 @@ export const createUnloadJournalServer = () => { if (current.revision !== request.expected_revision) { throw Object.assign(new Error('conflict'), { status: 409 }); } - const record = { + // A workspace sent with the content is stored in the same step, or refuses the content with it. + if (request.workspace && (current.workspace?.revision ?? null) !== request.workspace.expected_revision) { + throw Object.assign(new Error('conflict'), { status: 409 }); + } + const workspace = request.workspace + ? { + data: structuredClone(request.workspace.data), + revision: (current.workspace?.revision ?? 0) + 1, + updated_at: now, + } + : null; + const record: ProjectWithWorkspaceDTO = { ...current, data: structuredClone(request.data), + minimum_document_schema_version: Math.max( + current.minimum_document_schema_version, + request.minimum_document_schema_version ?? current.minimum_document_schema_version + ), name: request.name, revision: current.revision + 1, + ...(workspace ? { workspace, workspace_updated_at: now } : {}), }; records.set(projectId, record); if (server.dropNextUpdateResponse) { server.dropNextUpdateResponse = false; throw new TypeError('Failed to fetch'); } - return structuredClone(record); + return { ...structuredClone(record), workspace: structuredClone(workspace) }; }, }; return { api, server }; @@ -148,15 +218,19 @@ export const testUnloadJournalScenarios = (createStore: () => Promise { copiesStarted += 1; await copyGate; - return api.createProject( - { - data: input.document, - minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, - name: input.name, - project_id: input.copyProjectId, - }, - input.owner - ); + return { + ...(await api.createProject( + { + data: input.document, + minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + name: input.name, + project_id: input.copyProjectId, + }, + input.owner + )), + // A copy of a whole document: no workspace until its first save splits it. + workspace: null, + }; }, writerToken: `writer-${writer}`, }); @@ -166,6 +240,15 @@ export const testUnloadJournalScenarios = (createStore: () => Promise stateWith(state.projects.map((candidate) => (candidate.id === project.id ? { ...candidate, name } : candidate))); + /** A change to how one works on the project: its workspace alone. */ + const prompted = (state: WorkbenchState, prompt: string, projectId = project.id): WorkbenchState => + stateWith( + state.projects.map((candidate) => + candidate.id === projectId + ? { ...candidate, promptHistory: [{ negativePrompt: null, positivePrompt: prompt }] } + : candidate + ) + ); const serverName = () => server.records.get(project.id)?.name; /** Another tab's save, as the server sees it. */ const saveElsewhere = (name: string, revision: number) => { @@ -203,15 +286,20 @@ export const testUnloadJournalScenarios = (createStore: () => Promise, minimum_canvas_schema_version: 3, + minimum_document_schema_version: 4, name: project.name, project_id: project.id, revision: 1, updated_at: now, + workspace: { data: halves.workspace as unknown as Record, revision: 1, updated_at: now }, + workspace_updated_at: now, }); await api.saveSession(stateWith([project]), 'tab-a', {}); }); @@ -591,6 +679,32 @@ export const testUnloadJournalScenarios = (createStore: () => Promise { + const tab = openTab(); + const loaded = await tab.loadWorkbench(); + let release!: () => void; + server.gate = new Promise((resolve) => { + release = resolve; + }); + const saving = tab.saveWorkbench(prompted(loaded.state, 'Saved')); + await vi.waitFor(() => expect(server.workspaceUpdates).toBe(1)); + tab.journalBeforeUnload(prompted(loaded.state, 'Typed while it saved')); + server.gate = null; + release(); + await saving; + // The page unloads before another save. + + const reloaded = openTab(); + const recovered = await reloaded.loadWorkbench(); + + expect(recovered.conflicts).toEqual([]); + await reloaded.saveWorkbench(recovered.state); + expect(server.records.get(project.id)).toMatchObject({ + revision: 1, + workspace: { data: { promptHistory: [{ positivePrompt: 'Typed while it saved' }] }, revision: 3 }, + }); + }); + it('does not bring back an acknowledged journal whose deletion failed', async () => { const tab = openTab(); const loaded = await tab.loadWorkbench(); @@ -639,6 +753,143 @@ export const testUnloadJournalScenarios = (createStore: () => Promise { + const tab = openTab(); + const loaded = await tab.loadWorkbench(); + server.dropNextWorkspaceResponse = true; + await tab.saveWorkbench(prompted(loaded.state, 'Saved, answer lost')); + expect(server.records.get(project.id)).toMatchObject({ revision: 1, workspace: { revision: 2 } }); + tab.journalBeforeUnload(prompted(loaded.state, 'Typed after it')); + + const reloaded = openTab(); + const recovered = await reloaded.loadWorkbench(); + + expect(recovered.conflicts).toEqual([]); + await reloaded.saveWorkbench(recovered.state); + expect(server.records.get(project.id)).toMatchObject({ revision: 1, workspace: { revision: 3 } }); + }); + + describe('a new project whose first save reached the server unanswered', () => { + /** + * The project is staged while the server is unreachable and recovered by a reload, as the editor would hold it; + * its first save then sends content and workspace in one create, which the server stores and never answers. + */ + const createUnanswered = async (shape: (project: Project) => Project = (project) => project) => { + const first = openTab(); + const loaded = await first.loadWorkbench(); + const fresh = shape({ ...createDraftProject(loaded.state.projects), name: 'Created, answer lost' }); + server.offline = true; + await first.saveWorkbench(stateWith([...loaded.state.projects, fresh])); + server.offline = false; + const tab = openTab(); + const { state } = await tab.loadWorkbench(); + expect(state.projects.map(({ id }) => id)).toContain(fresh.id); + server.dropNextCreateResponse = true; + await tab.saveWorkbench(state); + expect(server.creates).toBe(1); + expect(server.records.get(fresh.id)).toMatchObject({ name: fresh.name, revision: 1 }); + expect(server.workspaceUpdates).toBe(0); + return { fresh, state, tab }; + }; + /** A workflow repeating an item id, which only a build before the split could write: created and kept whole. */ + const withRepeatedNode = (base: Project): Project => { + const [entry, ...rest] = base.workflows.entries; + const node = { data: { label: '', notes: '' }, id: 'node-1', position: { x: 0, y: 0 }, type: 'notes' }; + return { + ...base, + workflows: { + ...base.workflows, + entries: [{ ...entry!, document: { ...entry!.document, nodes: [node, node] } }, ...rest], + }, + } as unknown as Project; + }; + + it('settles both halves on the retry, which finds the create committed, and sends neither again', async () => { + const { fresh, state, tab } = await createUnanswered(); + + await tab.saveWorkbench(state); + + expect(server.creates).toBe(2); + expect(server.updates).toBe(0); + expect(server.workspaceUpdates).toBe(0); + expect(tab.hasPendingChanges()).toBe(false); + // A later edit to the workspace alone builds on the revision the create stored. + await tab.saveWorkbench(prompted(state, 'Typed after it', fresh.id)); + expect(server.updates).toBe(0); + expect(server.records.get(fresh.id)).toMatchObject({ revision: 1, workspace: { revision: 2 } }); + }); + + it('saves a workspace edited before the retry on top of the one the create stored, not under it', async () => { + const { fresh, state, tab } = await createUnanswered(); + + // The retry carries the newer workspace; the settled create answers with the stored one, which it is not. + await tab.saveWorkbench(prompted(state, 'Typed before the retry', fresh.id)); + + expect(server.creates).toBe(2); + expect(server.updates).toBe(0); + expect(server.workspaceUpdates).toBe(1); + expect(tab.hasPendingChanges()).toBe(false); + expect(server.records.get(fresh.id)).toMatchObject({ + revision: 1, + workspace: { data: { promptHistory: [{ positivePrompt: 'Typed before the retry' }] }, revision: 2 }, + }); + await expect(store.listForProject(fresh.id)).resolves.toMatchObject({ items: [] }); + }); + + it('rebases a journal written after it onto both halves the server holds, without a conflict', async () => { + const { fresh, state, tab } = await createUnanswered(); + expect(server.records.get(fresh.id)).toMatchObject({ workspace: { revision: 1 } }); + const renamed = stateWith( + state.projects.map((candidate) => + candidate.id === fresh.id ? { ...candidate, name: 'Typed after it' } : candidate + ) + ); + tab.journalBeforeUnload(prompted(renamed, 'Typed after it', fresh.id)); + + const reloaded = openTab(); + const recovered = await reloaded.loadWorkbench(); + + expect(recovered.conflicts).toEqual([]); + expect(recovered.state.projects).toContainEqual( + expect.objectContaining({ id: fresh.id, name: 'Typed after it' }) + ); + await reloaded.saveWorkbench(recovered.state); + expect(server.creates).toBe(1); + expect(server.records.get(fresh.id)).toMatchObject({ + name: 'Typed after it', + revision: 2, + workspace: { data: { promptHistory: [{ positivePrompt: 'Typed after it' }] }, revision: 2 }, + }); + }); + + it('rebases a journal onto a project created whole, whose document the server holds with its board', async () => { + const { fresh, state, tab } = await createUnanswered(withRepeatedNode); + expect(server.records.get(fresh.id)).toMatchObject({ minimum_document_schema_version: 3, workspace: null }); + const renamed = stateWith( + state.projects.map((candidate) => + candidate.id === fresh.id ? { ...candidate, name: 'Typed after it' } : candidate + ) + ); + tab.journalBeforeUnload(renamed); + + const reloaded = openTab(); + const recovered = await reloaded.loadWorkbench(); + + expect(recovered.conflicts).toEqual([]); + const page = recovered.state.projects.find(({ id }) => id === fresh.id)!; + expect(page.name).toBe('Typed after it'); + await reloaded.saveWorkbench(recovered.state); + expect(server.creates).toBe(1); + expect(server.updates).toBe(1); + expect(server.workspaceUpdates).toBe(0); + // Still whole, and exactly the page's document with the board the create minted applied. + const record = server.records.get(fresh.id)!; + expect(record).toMatchObject({ minimum_document_schema_version: 3, revision: 2, workspace: null }); + expect(record.data).toEqual(serializeProjectDocumentV3(withAuthoritativeProjectBoard(page, `board-${fresh.id}`))); + expect(JSON.stringify(record.data)).toContain(`"projectBoardId":"board-${fresh.id}"`); + }); + }); + it('still raises a conflict when the server moved past an unanswered save to something else', async () => { const tab = openTab(); const loaded = await tab.loadWorkbench(); diff --git a/invokeai/frontend/webv2/src/workbench/projects/useProjectFileActions.ts b/invokeai/frontend/webv2/src/workbench/projects/useProjectFileActions.ts index 790a9b75c69..92e7230d0e6 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/useProjectFileActions.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/useProjectFileActions.ts @@ -11,7 +11,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ProjectRecordDTO } from './api'; +import type { ProjectWithWorkspaceDTO } from './api'; import type { DuplicatedProject } from './library'; import type { ProjectFileDirection } from './projectFileErrors'; @@ -60,7 +60,9 @@ const runReported = async ( }; /** Picker dismissal resolves without work or a toast. */ -export const useImportProjectFile = (onImported: (record: ProjectRecordDTO) => Promise | void): (() => void) => { +export const useImportProjectFile = ( + onImported: (record: ProjectWithWorkspaceDTO) => Promise | void +): (() => void) => { const { t } = useTranslation(); const { requestReferencesOnlyImport } = useProjectFileOptions(); const queryClient = useQueryClient(); diff --git a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts index 5d130227b74..b173db5d85e 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts @@ -64,6 +64,8 @@ export interface WorkbenchPreferences { enableModelDescriptions: boolean; /** Toast when an invocation is added to the queue. The notification center records it regardless. */ notifyOnEnqueue: boolean; + /** Project export carries the exporter's workspace (layout, generation settings, prompt history); the last choice. */ + exportIncludesWorkspace: boolean; /** Write numeric attention weights (`(word)1.1`) when the attention hotkeys insert them. */ preferNumericAttentionStyle: boolean; /** Color prompt syntax in prompt fields; changes rendering only. */ diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.ts b/invokeai/frontend/webv2/src/workbench/settings/store.ts index 3e13c1dd05b..7bf67bbeda4 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/store.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/store.ts @@ -55,6 +55,7 @@ export const DEFAULT_PREFERENCES: WorkbenchPreferences = { developerPerformanceTimingsEnabled: false, enableInformationalPopovers: true, enableModelDescriptions: true, + exportIncludesWorkspace: true, generatePresets: [], generateSectionsOpen: {}, highContrast: false, @@ -315,6 +316,10 @@ export const normalizeWorkbenchPreferences = (preferences?: WorkbenchPreferences typeof preferences?.enableModelDescriptions === 'boolean' ? preferences.enableModelDescriptions : DEFAULT_PREFERENCES.enableModelDescriptions, + exportIncludesWorkspace: + typeof preferences?.exportIncludesWorkspace === 'boolean' + ? preferences.exportIncludesWorkspace + : DEFAULT_PREFERENCES.exportIncludesWorkspace, generatePresets: normalizeGeneratePresets(preferences?.generatePresets), generateSectionsOpen: normalizeGenerateSectionsOpen(preferences?.generateSectionsOpen), krea2RebalancePresets: normalizeRebalancePresets(preferences?.krea2RebalancePresets), diff --git a/invokeai/frontend/webv2/src/workbench/shell/PasteMediaDialog.tsx b/invokeai/frontend/webv2/src/workbench/shell/PasteMediaDialog.tsx index 42468497841..729da8dee1f 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/PasteMediaDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/PasteMediaDialog.tsx @@ -1,4 +1,4 @@ -import type { GalleryBoard, GalleryImage, GalleryItem, GalleryItemKind } from '@features/gallery/contracts'; +import type { GalleryImage, GalleryItem, GalleryItemKind } from '@features/gallery/contracts'; import type { GalleryCanvasImportDestination } from '@workbench/canvas-operations/api'; import { Box, Flex, Image, Portal, SimpleGrid, Stack, Text } from '@chakra-ui/react'; @@ -12,13 +12,14 @@ import { getGalleryView, isGalleryImageItem, } from '@features/gallery/contracts'; -import { galleryBoardsOptions } from '@features/gallery/queries'; -import { useGalleryUploadAction } from '@features/gallery/react'; +import { useGalleryUploadAction } from '@features/gallery/picker'; +import { canAddToCachedGalleryBoard, galleryBoardsOptions } from '@features/gallery/queries'; +import { useHostGalleryBoards } from '@features/gallery/react'; import { createGenerateFormValuesSelector } from '@features/generation/react'; import { useMountEffect } from '@platform/react/useMountEffect'; import { Button, CloseButton } from '@platform/ui'; import { Dialog } from '@platform/ui/Dialog'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { getGalleryCanvasImportMenuItems, useDeletionConfirmation, @@ -41,7 +42,6 @@ type PasteMediaChoice = const selectGenerateValues = createGenerateFormValuesSelector(); const CANVAS_ITEMS = getGalleryCanvasImportMenuItems(true); const PREVIEW_LIMIT = 6; -const EMPTY_BOARDS: GalleryBoard[] = []; /** Upload and follow-on actions report their own failures. */ const routePastedFiles = async ( @@ -111,9 +111,10 @@ export const PasteMediaDialog = ({ const generateValues = useWidgetValuesSelector('generate', selectGenerateValues); // The gallery's own boards query (same key, shared cache): the board the // person is looking at may be archived or a date bucket, which the default - // listing omits. + // listing omits. Folded as the host shows boards, so a board is named here as + // the Gallery widget names it. const gallerySettings = getGallerySettings(galleryValues); - const boards = + const boards = useHostGalleryBoards( useQuery( galleryBoardsOptions({ includeArchived: gallerySettings.showArchivedBoards, @@ -121,10 +122,22 @@ export const PasteMediaDialog = ({ orderBy: gallerySettings.boardOrderBy, orderDir: gallerySettings.boardOrderDir, }) - ).data ?? EMPTY_BOARDS; + ).data + ); // Uploads land where generation results do: the auto-add board — never a - // date bucket, which cannot hold items. - const destinationBoardId = getGalleryAutoAddBoardId(galleryValues) ?? 'none'; + // date bucket, which cannot hold items. Judged from the listing of every + // readable board, observed while this short-lived dialog is open so the + // label follows it as it arrives; the upload action ensures the same listing + // as the paste is chosen (sharing this read) and reads the getter after, so + // an archived or revoked board is decided from a listing, not for want of one. + useQuery(galleryBoardsOptions({ includeArchived: true })); + const queryClient = useQueryClient(); + const getDestinationBoardId = useCallback( + () => + getGalleryAutoAddBoardId(galleryValues, (boardId) => canAddToCachedGalleryBoard(boardId, queryClient)) ?? 'none', + [galleryValues, queryClient] + ); + const destinationBoardId = getDestinationBoardId(); const selectedBoardId = getGallerySelectedBoardId(galleryValues, boards); const galleryView = getGalleryView(galleryValues); const getCurrentGalleryLocation = useCallback( @@ -134,7 +147,7 @@ export const PasteMediaDialog = ({ const uploadFiles = useGalleryUploadAction({ boards, getCurrentGalleryLocation, - selectedBoardId: destinationBoardId, + selectedBoardId: getDestinationBoardId, }); const { dialog: deletionDialog, requestDeletionConfirmation } = useDeletionConfirmation(); const actions = useImageActions({ boards, generateValues, projectId, requestDeletionConfirmation }); diff --git a/invokeai/frontend/webv2/src/workbench/shell/PasteMediaRuntime.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/shell/PasteMediaRuntime.browser.test.tsx index b70e98c6e49..3249b194a11 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/PasteMediaRuntime.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/PasteMediaRuntime.browser.test.tsx @@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => ({ project: null as unknown as Project, sendToCanvas: vi.fn(), uploadFiles: vi.fn(), - uploadOptions: null as null | { selectedBoardId: string }, + uploadOptions: null as null | { selectedBoardId: string | (() => string) }, useAsReferenceImage: vi.fn(), })); @@ -33,13 +33,15 @@ vi.mock('@workbench/WorkbenchContext', () => ({ useActiveProjectSelector: (selector: (project: Project) => unknown) => selector(mocks.project), useWidgetValuesSelector: () => ({}), })); -vi.mock('@features/gallery/react', () => ({ - useGalleryUploadAction: (options: { selectedBoardId: string }) => { +vi.mock('@features/gallery/picker', async (importOriginal) => ({ + ...(await importOriginal>()), + useGalleryUploadAction: (options: { selectedBoardId: string | (() => string) }) => { mocks.uploadOptions = options; return mocks.uploadFiles; }, })); vi.mock('@features/gallery/queries', () => ({ + canAddToCachedGalleryBoard: () => true, galleryBoardsOptions: () => ({ queryFn: () => Promise.resolve([ @@ -212,7 +214,10 @@ describe('PasteMediaRuntime', () => { paste(document.body, [imageFile()]); await expect.element(page.getByRole('button', { name: /Upload to Gallery/ })).toHaveAccessibleName(/Moodboard/); - expect(mocks.uploadOptions?.selectedBoardId).toBe('board-1'); + // Read by the upload action as the paste is chosen, once it has the listing the board is judged from. + const { selectedBoardId } = mocks.uploadOptions!; + expect(selectedBoardId).toBeTypeOf('function'); + expect((selectedBoardId as () => string)()).toBe('board-1'); }); it('hides the reference option while the current model cannot take another reference image', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/shell/ProjectConflictBanner.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/shell/ProjectConflictBanner.browser.test.tsx index 966618055b8..06692c8601a 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/ProjectConflictBanner.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/ProjectConflictBanner.browser.test.tsx @@ -15,7 +15,7 @@ const harness = vi.hoisted(() => ({ closeProject: vi.fn<() => ProjectCommandResult>(() => ({ ok: true })), conflict: undefined as | { detectedAt: string; kind: 'deleted' } - | { detectedAt: string; kind: 'revision'; serverRevision: number } + | { detectedAt: string; half?: 'workspace'; kind: 'revision'; serverRevision: number } | undefined, createProject: vi.fn(), deleteRecoverableDraft: vi.fn(() => Promise.resolve()), @@ -50,15 +50,18 @@ vi.mock('@platform/browser/downloadBlob', () => ({ downloadText: harness.downloa // it closes after confirming even when the confirmation fails. vi.mock('@platform/ui/ConfirmDialog', () => ({ ConfirmDialog: ({ + body, isOpen, onClose, onConfirm, }: { + body: string; isOpen: boolean; onClose(): void; onConfirm(): Promise | void; }) => (
+ {isOpen ?

{body}

: null} {isOpen ? (