From e15d725984d5cb049f403993ee4f99b6ea9f0adb Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 20 Oct 2026 05:59:50 -0500 Subject: [PATCH 1/5] feat(workflows): add public library workflows --- .../Multi-User Mode/user-guide.mdx | 66 ++-- .../frontend/webv2/public/locales/en.json | 8 + .../scripts/mock-backend-fixtures.test.mjs | 79 +++++ .../frontend/webv2/scripts/mock-backend.mjs | 49 ++- .../scripts/run-project-file-journey.mjs | 6 +- .../webv2/src/features/workflow/core/types.ts | 2 + .../src/features/workflow/data/api.test.ts | 20 +- .../webv2/src/features/workflow/data/api.ts | 16 + .../workflow/data/libraryBrowseStore.test.ts | 140 ++++++++ .../workflow/data/libraryBrowseStore.ts | 44 ++- .../workflow/data/publication.test.ts | 53 ++- .../src/features/workflow/data/publication.ts | 63 +++- ...dingLibraryWorkflowLoader.browser.test.tsx | 4 +- .../ui/PendingLibraryWorkflowLoader.tsx | 10 +- .../ui/WorkflowMenuItems.browser.test.tsx | 47 +++ .../workflow/ui/WorkflowWidgetChrome.tsx | 4 +- .../GraphPreviewDialog.browser.test.tsx | 263 ++++++++++++++- .../graph-preview/GraphPreviewOpenAsMenu.tsx | 314 +++++++++++++++--- .../ui/library/ProjectWorkflowsView.tsx | 4 +- .../ui/library/WorkflowLibraryCard.tsx | 12 + ...orkflowLibraryDetailPanel.browser.test.tsx | 136 +++++++- .../ui/library/WorkflowLibraryDetailPanel.tsx | 137 +++++++- .../WorkflowLibraryDialog.browser.test.tsx | 199 ++++++++++- .../ui/library/WorkflowLibraryDialog.tsx | 47 ++- .../ui/library/WorkflowLibraryGrid.tsx | 4 + .../ui/library/WorkflowLibraryThumbnail.tsx | 5 +- .../WorkflowPublicationHost.browser.test.tsx | 168 +++++++++- .../ui/library/WorkflowPublicationHost.tsx | 67 +++- .../ui/library/projectWorkflowEntries.ts | 8 +- .../useOpenLibraryWorkflow.browser.test.tsx | 118 ++++++- .../ui/library/useOpenLibraryWorkflow.ts | 8 +- .../ui/library/useWorkflowPublication.ts | 32 +- .../features/workflow/ui/workflowUiStore.ts | 4 +- .../src/workbench/projectWorkflows.test.ts | 42 +++ .../webv2/src/workbench/projectWorkflows.ts | 8 +- 35 files changed, 2014 insertions(+), 173 deletions(-) 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 f40b1a6b810..13ac55b70c9 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 @@ -4,7 +4,8 @@ description: How to use InvokeAI in multi-user mode as an end user. sidebar: order: 3 --- -import { Steps } from '@astrojs/starlight/components' + +import { Steps } from '@astrojs/starlight/components'; ## Overview @@ -48,15 +49,17 @@ If you're the first person to access a fresh InvokeAI installation in multi-user Now: -1. Enter your email address (this will be your login name) -2. Create a display name (this will be the name other users see) -3. Choose a strong password. The following criteria are required with `strict_password_checking: true`. - - At least 8 characters long - - Contains uppercase letters - - Contains lowercase letters - - Contains numbers -4. Confirm your password -5. Click **Create Administrator Account** + +1. Enter your email address (this will be your login name). + +2. Create a display name (this will be the name other users see). + +3. Choose a strong password. With `strict_password_checking: true`, it must have at least 8 characters, including uppercase letters, lowercase letters, and numbers. + +4. Confirm your password. + +5. Click **Create Administrator Account**. + With `strict_password_checking` disabled, you'll be warned if you choose a @@ -81,10 +84,15 @@ This will take you to the User Management screen... The User Management screen also allows you to: + 1. Temporarily change a user's status to Inactive, preventing them from logging in to Invoke. + 2. Edit a user (by clicking on the pencil icon) to change the user's display name or password. + 3. Permanently delete a user. + 4. Grant a user Administrator privileges. + --- @@ -119,7 +127,7 @@ As a regular user, you can: - Customize your UI preferences (theme, hotkeys, etc.) - View available models (read-only access to Model Manager) - 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 +- Browse public workflows in the **Shared** library tab, including your own public workflows You cannot: @@ -144,7 +152,7 @@ Administrators have all regular user capabilities, plus: - Full model management (add, delete, configure models) - Create and manage user accounts - View and manage all users' generation queues -- View and manage all users' boards, images, and workflows (including system-owned legacy content) +- View all users' workflows in the Library; rename, delete, and visibility controls are shown only to each workflow's owner - Access system configuration - Grant or revoke admin privileges @@ -156,12 +164,12 @@ Administrators have all regular user capabilities, plus: 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: -| 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. | +| 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. | +| **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. | A board's **general access** sets the role every other user gets: @@ -189,14 +197,16 @@ If an InvokeAI instance was in multiuser mode and then restarted in single user ### Workflows -Each user has their own private workflow library. Workflows you create are visible only to you by default. +In multi-user mode, new workflows saved to the Library are private by default. To change a saved workflow's visibility, open **Yours**, select it, and use **Visibility** in the detail panel to choose **Private** or **Public**. A public workflow is visible to signed-in users of this same InvokeAI instance; it is not published to the public internet. + +You can also choose **Public workflow** when using **Save to Library** to create a public Library copy of a project workflow. This does not make the project-only workflow public. Changing a Library record's visibility does not publish unsaved project edits; use **Update Template** to update the Library copy's content. **Update Template** preserves that copy's visibility. -You can share a workflow with other users by marking it as **shared** (public). Shared workflows appear in all users' workflow libraries and can be opened by anyone, but only the owner (or an administrator) can modify or delete them. +Public workflows appear in **Shared**, including your own public workflows. Callable public workflows can also appear in the **Call Saved Workflow** picker. -To share a workflow, open it and use the sharing controls to toggle its public/shared status. +Other users can browse, open, duplicate, or download your public workflows. In the Library, only the workflow's owner is offered rename, delete, and visibility controls. -:::caution[Preexisting workflows after enabling multi-user mode] -When you enable multi-user mode for the first time on an existing InvokeAI installation, all workflows that were created before multi-user mode was activated will appear in the **shared workflows** section. These preexisting workflows are owned by the internal "system" account and are visible to all users. Administrators can edit or delete these shared legacy workflows. Regular users can view and use them but cannot modify them. +:::note[Preexisting Library workflows] +User workflows already in the Library before per-user workflow ownership was introduced are migrated as public records owned by the internal system account. They appear in **Shared**. The Library does not offer rename, delete, or visibility controls for these records to other accounts, but users can still open, duplicate, or download them. ::: ### The Generation Queue @@ -276,9 +286,13 @@ These settings are stored per-user and won't affect other users. **Possible Causes:** -1. **Filter Applied:** Check if a filter is hiding content -2. **Wrong User:** Ensure you're logged in with the correct account -3. **Archived Board:** Check the "Show Archived" option + +1. **Filter Applied:** Check if a filter is hiding content. + +2. **Wrong User:** Ensure you're logged in with the correct account. + +3. **Archived Board:** Check the "Show Archived" option. + **Solution:** @@ -329,7 +343,7 @@ Not unless you give them access. Boards start **restricted**: only you and admin ### Can I share my workflows with others? -Yes. You can mark any workflow as shared (public), which makes it visible to all users. Other users can view and use shared workflows, but only you or an administrator can modify or delete them. +Yes. In multi-user mode, make a saved Library workflow public from **Yours**, or choose **Public workflow** when saving a new Library copy. Public workflows are visible to signed-in users of the same InvokeAI instance and appear in **Shared**. Other users can open, duplicate, or download them; only the owner is offered rename, delete, and visibility controls in the Library. For a project copy linked to a public workflow, only its owner is offered **Update Template**. Other users can choose **Save as new** to create their own Library workflow. ### How long do sessions last? diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 9cd223302e6..649951d55f1 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -5037,6 +5037,11 @@ "openProjectChanged": "The project changed while \"{{name}}\" was loading; nothing was added.", "openProjectWorkflowHint": "Makes it the active workflow", "previewGraph": "Preview graph", + "private": "Private", + "public": "Public", + "publicVisibilityHelp": "Visible to users of this InvokeAI instance.", + "publicVisibilityPartial": "The template was saved, but its public visibility could not be confirmed ({{message}}). Retry to make it public.", + "publicWorkflow": "Public workflow", "projectWorkflowCount_one": "{{count}} workflow in this project", "projectWorkflowCount_other": "{{count}} workflows in this project", "remove": "Remove", @@ -5081,6 +5086,7 @@ "savedCreatedBody": "Saved \"{{name}}\" to the library.", "savedUpdatedBody": "Updated \"{{name}}\" in the library.", "searchPlaceholder": "Search names, tags, or descriptions", + "shared": "Shared", "sourceBundled": "From a bundled template (read-only)", "sourceNone": "Not linked to a library template", "sourceRevision": "Linked to library template at revision {{revision}}", @@ -5098,6 +5104,8 @@ "thumbnailUpdateFailed": "Failed to update thumbnail", "thumbnailUpdated": "Thumbnail updated", "thumbnailUpdating": "Updating thumbnail…", + "visibility": "Visibility", + "visibilityUpdateFailed": "Failed to update workflow visibility", "title": "Workflows", "unavailableBundledBody": "\"{{name}}\" came from a bundled template, which cannot be changed. Save it as a new template instead.", "unavailableForbiddenBody": "The library template behind \"{{name}}\" belongs to someone else. Save it as a new template instead.", diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index 8f4d1fbcb50..21eea7181fc 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -1403,3 +1403,82 @@ test('runtime config PATCH keeps the other writable field and validates the queu assert.equal((await getJson(backend, '/api/v1/app/invocation_cache/status')).enabled, false); }); }); + +test('workflow creation defaults public for single-user callers and private for multiuser callers', async () => { + const backend = await startMockBackend(0, { profile: 'empty' }); + + try { + const createWorkflow = (name) => + getJson(backend, '/api/v1/workflows', { + body: JSON.stringify({ workflow: { name, nodes: [], tags: 'local' } }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + + const singleUser = await createWorkflow('Single-user template'); + assert.equal(singleUser.is_public, true); + + await getJson(backend, '/__faults', { + body: JSON.stringify({ intermediatesCaller: 'user' }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + + const multiuser = await createWorkflow('Multiuser template'); + assert.equal(multiuser.is_public, false); + } finally { + await backend.close(); + } +}); + +test('workflow visibility PATCH mirrors the shared tag without changing revision and filters lists', async () => { + const backend = await startMockBackend(0, { profile: 'empty' }); + + try { + const createWorkflow = (name) => + getJson(backend, '/api/v1/workflows', { + body: JSON.stringify({ workflow: { name, nodes: [], tags: 'local' } }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + const singleUser = await createWorkflow('Visible template'); + const target = await createWorkflow('Toggle template'); + const revision = target.revision; + const patchVisibility = (isPublic) => + getJson(backend, `/api/v1/workflows/i/${encodeURIComponent(target.workflow_id)}/is_public`, { + body: JSON.stringify({ is_public: isPublic }), + headers: { 'content-type': 'application/json' }, + method: 'PATCH', + }); + const listNames = async (isPublic) => { + const params = new URLSearchParams({ categories: 'user', is_public: String(isPublic), per_page: '100' }); + + return (await getJson(backend, `/api/v1/workflows/?${params}`)).items.map((item) => item.name).sort(); + }; + const tagsOf = (value) => + String(value ?? '') + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) + .sort(); + + const hidden = await patchVisibility(false); + assert.equal(hidden.is_public, false); + assert.equal(hidden.revision, revision); + assert.deepEqual(tagsOf(hidden.tags), ['local']); + assert.deepEqual(tagsOf(hidden.workflow.tags), ['local']); + assert.deepEqual(await listNames(true), ['Visible template']); + assert.deepEqual(await listNames(false), ['Toggle template']); + + const shared = await patchVisibility(true); + assert.equal(shared.is_public, true); + assert.equal(shared.revision, revision); + assert.deepEqual(tagsOf(shared.tags), ['local', 'shared']); + assert.deepEqual(tagsOf(shared.workflow.tags), ['local', 'shared']); + assert.deepEqual(await listNames(true), ['Toggle template', 'Visible template']); + assert.deepEqual(await listNames(false), []); + assert.equal(singleUser.is_public, true); + } finally { + await backend.close(); + } +}); diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 7d101a6cbda..4719e6b6555 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -2660,6 +2660,18 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { .split(',') .map((tag) => tag.trim()) .filter(Boolean); + const workflowTagsForVisibility = (value, isPublic) => { + const tags = String(value ?? '') + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag && tag !== 'shared'); + + if (isPublic) { + tags.push('shared'); + } + + return tags.join(','); + }; if (path === '/api/v1/workflows/tags') { const categories = url.searchParams.getAll('categories'); @@ -2689,6 +2701,37 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, counts); } + const visibilityMatch = /^\/api\/v1\/workflows\/i\/([^/]+)\/is_public$/.exec(path); + if (visibilityMatch) { + if (method !== 'PATCH') { + return json(405, { detail: `No mock for ${method} ${path}` }); + } + + const workflowId = decodeURIComponent(visibilityMatch[1]); + const workflow = state.workflows.get(workflowId); + + if (!workflow) { + return json(404, { detail: 'Workflow not found' }); + } + if (workflow.category === 'default') { + return json(403, { detail: 'Bundled workflows cannot be modified' }); + } + + const body = await readJsonBody(request); + if (typeof body.is_public !== 'boolean') { + return json(422, { detail: 'is_public must be a boolean' }); + } + + workflow.is_public = body.is_public; + workflow.tags = workflowTagsForVisibility(workflow.tags, body.is_public); + workflow.workflow = { + ...workflow.workflow, + tags: workflowTagsForVisibility(workflow.workflow?.tags, body.is_public), + }; + + return json(200, workflowRecord(workflow)); + } + const workflowMatch = /^\/api\/v1\/workflows\/i\/([^/]+)(\/opened_at|\/thumbnail)?$/.exec(path); if (workflowMatch) { const workflowId = decodeURIComponent(workflowMatch[1]); @@ -2814,7 +2857,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { category: 'user', created_at: timestamp(state), description: content.description ?? '', - is_public: true, + is_public: !isMultiuserCaller(faults), name: content.name ?? '', opened_at: null, revision: 1, @@ -2836,6 +2879,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const categories = url.searchParams.getAll('categories'); const query = url.searchParams.get('query')?.trim().toLocaleLowerCase() ?? ''; const tags = url.searchParams.getAll('tags'); + const isPublic = url.searchParams.get('is_public'); // The API pages from 0; the client asks for page 0 first. const page = Math.max(0, Number(url.searchParams.get('page') ?? 0) || 0); const perPage = Math.max(1, Number(url.searchParams.get('per_page') ?? 20) || 20); @@ -2843,7 +2887,8 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { (workflow) => (categories.length === 0 || categories.includes(workflow.category)) && (!query || `${workflow.name} ${workflow.description}`.toLocaleLowerCase().includes(query)) && - (tags.length === 0 || tags.some((tag) => workflowTags(workflow).includes(tag))) + (tags.length === 0 || tags.some((tag) => workflowTags(workflow).includes(tag))) && + (isPublic === null || String(workflow.is_public) === isPublic) ); const pages = Math.max(1, Math.ceil(items.length / perPage)); const pageItems = items.slice(page * perPage, (page + 1) * perPage).map(workflowListItem); diff --git a/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs b/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs index da91af29ddd..b9fbdf3e08c 100644 --- a/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs +++ b/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs @@ -710,7 +710,11 @@ const runWorkflowCollection = async ({ browser, contexts, errors, imported }) => 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.deepEqual(record.data.workflows.entries[1].source, { + libraryWorkflowId: saved.workflow_id, + ownerUserId: 'fixture-user', + revision: 1, + }); 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. diff --git a/invokeai/frontend/webv2/src/features/workflow/core/types.ts b/invokeai/frontend/webv2/src/features/workflow/core/types.ts index af24752cf69..f9e734f8d80 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/types.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/types.ts @@ -272,6 +272,8 @@ export interface ProjectWorkflowSource { libraryWorkflowId: string; /** The template content revision loaded or last written; null when unknown (documents migrated from older schemas). */ revision: number | null; + /** Stable owner metadata lets each account check write access against its current session. */ + ownerUserId?: string; } /** The newest successful run's final output, owned by the project workflow that submitted it. */ diff --git a/invokeai/frontend/webv2/src/features/workflow/data/api.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/api.test.ts index 598cfd8037e..637aa454713 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/api.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/api.test.ts @@ -4,7 +4,13 @@ const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), apiFetchJson: vi.fn() })); vi.mock('@platform/transport/http', () => ({ apiFetch: mocks.apiFetch, apiFetchJson: mocks.apiFetchJson })); -import { getAllWorkflowTags, getLibraryWorkflowRecord, getWorkflowTagCounts, listLibraryWorkflows } from './api'; +import { + getAllWorkflowTags, + getLibraryWorkflowRecord, + getWorkflowTagCounts, + listLibraryWorkflows, + setLibraryWorkflowPublic, +} from './api'; describe('workflow library api', () => { beforeEach(() => { @@ -56,6 +62,18 @@ describe('workflow library api', () => { expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/workflows/i/workflow-1', { signal: undefined }); }); + it('updates workflow visibility with the workflow record endpoint', async () => { + const record = { workflow_id: 'workflow-1', name: 'Workflow 1', workflow: {}, is_public: true }; + mocks.apiFetchJson.mockResolvedValue(record); + + await expect(setLibraryWorkflowPublic('team/workflow id', true)).resolves.toEqual(record); + expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/workflows/i/team%2Fworkflow%20id/is_public', { + body: JSON.stringify({ is_public: true }), + method: 'PATCH', + signal: undefined, + }); + }); + it('gets tag counts for the given tags', async () => { mocks.apiFetchJson.mockResolvedValue({ lora: 2, upscaling: 1 }); diff --git a/invokeai/frontend/webv2/src/features/workflow/data/api.ts b/invokeai/frontend/webv2/src/features/workflow/data/api.ts index 3da988bb07f..b25e8e8e8ff 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/api.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/api.ts @@ -228,6 +228,22 @@ export const updateLibraryWorkflow = async ( } }; +export const setLibraryWorkflowPublic = async ( + workflowId: string, + isPublic: boolean, + signal?: AbortSignal +): Promise => { + try { + return await apiFetchJson(`/api/v1/workflows/i/${encodeURIComponent(workflowId)}/is_public`, { + body: JSON.stringify({ is_public: isPublic }), + method: 'PATCH', + signal, + }); + } catch (error) { + return rethrowAsRefusal(error); + } +}; + export const deleteLibraryWorkflow = async (workflowId: string, signal?: AbortSignal): Promise => { await apiFetch(`/api/v1/workflows/i/${encodeURIComponent(workflowId)}`, { method: 'DELETE', signal }); }; diff --git a/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.test.ts index fe3f5610500..5ef9326534c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.test.ts @@ -169,6 +169,63 @@ describe('workflow library page fetching', () => { ); }); + it('queries shared workflows and their tags as public user workflows', async () => { + api.getAllWorkflowTags.mockResolvedValue(['shared-tag']); + api.getWorkflowTagCounts.mockResolvedValue({ 'shared-tag': 2 }); + api.listLibraryWorkflows.mockImplementation(({ isPublic, page }: { isPublic?: boolean; page: number }) => + Promise.resolve(buildPage([buildItem(isPublic ? `shared-${page}` : 'yours')], { page, pages: 2, total: 2 })) + ); + await browse.ensureWorkflowLibraryBrowseLoaded(); + + expect(api.listLibraryWorkflows.mock.calls[0]?.[0]).toMatchObject({ category: 'user', page: 0, perPage: 20 }); + expect(api.listLibraryWorkflows.mock.calls[0]?.[0]).not.toHaveProperty('isPublic'); + + api.listLibraryWorkflows.mockClear(); + api.getAllWorkflowTags.mockClear(); + api.getWorkflowTagCounts.mockClear(); + browse.setWorkflowLibraryBrowseFilter({ category: 'user', isPublic: true, tag: null }); + + await vi.waitFor(() => { + expect(browse.getWorkflowLibraryBrowseSnapshot().status).toBe('loaded'); + expect(api.getWorkflowTagCounts).toHaveBeenCalled(); + }); + + expect(api.listLibraryWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ category: 'user', isPublic: true, page: 0, perPage: 20 }) + ); + expect(api.listLibraryWorkflows.mock.calls.every(([params]) => !('callable' in params))).toBe(true); + expect(api.getAllWorkflowTags).toHaveBeenCalledWith( + expect.objectContaining({ categories: ['user'], isPublic: true }) + ); + expect(api.getWorkflowTagCounts).toHaveBeenCalledWith( + expect.objectContaining({ categories: ['user'], isPublic: true, tags: ['shared-tag'] }) + ); + + browse.loadNextWorkflowLibraryPage(); + await vi.waitFor(() => expect(browse.getWorkflowLibraryBrowseSnapshot().page).toBe(1)); + expect(api.listLibraryWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ category: 'user', isPublic: true, page: 1, perPage: 20 }) + ); + + api.listLibraryWorkflows.mockClear(); + api.getAllWorkflowTags.mockClear(); + api.getWorkflowTagCounts.mockClear(); + await browse.refreshWorkflowLibraryBrowse(); + + expect(api.listLibraryWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ category: 'user', isPublic: true, page: 0, perPage: 20 }) + ); + expect(api.listLibraryWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ category: 'user', isPublic: true, page: 1, perPage: 20 }) + ); + expect(api.getAllWorkflowTags).toHaveBeenCalledWith( + expect.objectContaining({ categories: ['user'], isPublic: true }) + ); + expect(api.getWorkflowTagCounts).toHaveBeenCalledWith( + expect.objectContaining({ categories: ['user'], isPublic: true, tags: ['shared-tag'] }) + ); + }); + it('ignores a repeated filter patch that changes nothing', async () => { await browse.ensureWorkflowLibraryBrowseLoaded(); api.listLibraryWorkflows.mockClear(); @@ -480,6 +537,35 @@ describe('workflow library browse refresh', () => { expect(api.listLibraryWorkflows).toHaveBeenCalledTimes(1); }); + it('removes a workflow from the active Shared view after visibility invalidation', async () => { + const sharedWorkflow = buildItem('shared', { is_public: true, user_id: 'user-a' }); + api.listLibraryWorkflows.mockImplementation(({ isPublic }: { isPublic?: boolean }) => + Promise.resolve(buildPage(isPublic ? [sharedWorkflow] : [])) + ); + + await browse.ensureWorkflowLibraryBrowseLoaded(); + browse.setWorkflowLibraryBrowseFilter({ category: 'user', isPublic: true, tag: null }); + await vi.waitFor(() => { + const snapshot = browse.getWorkflowLibraryBrowseSnapshot(); + expect(snapshot.status).toBe('loaded'); + expect(snapshot.entries.map((entry) => entry.item.workflow_id)).toEqual(['shared']); + }); + + api.listLibraryWorkflows.mockResolvedValue(buildPage([])); + invalidate(); + + await vi.waitFor(() => { + expect(browse.getWorkflowLibraryBrowseSnapshot()).toMatchObject({ + entries: [], + filter: { category: 'user', isPublic: true }, + status: 'loaded', + }); + }); + expect(api.listLibraryWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ category: 'user', isPublic: true, page: 0, perPage: 20 }) + ); + }); + it('does not refresh while the store is idle', async () => { invalidate(); @@ -593,6 +679,24 @@ describe('workflow library browse account ownership', () => { userTotal: null, }); }); + + it('drops an in-flight shared page when the account switches', async () => { + await browse.ensureWorkflowLibraryBrowseLoaded(); + + const stale = createDeferred(); + api.listLibraryWorkflows.mockReturnValueOnce(stale.promise); + browse.setWorkflowLibraryBrowseFilter({ isPublic: true }); + + account.accountLifecycle.activate('user-b'); + stale.resolve(buildPage([buildItem('other-account-shared')])); + await flushAsyncWork(); + + expect(browse.getWorkflowLibraryBrowseSnapshot()).toMatchObject({ + entries: [], + filter: { category: 'user', search: '', tag: null }, + status: 'idle', + }); + }); }); describe('workflow library user total probe', () => { @@ -653,6 +757,42 @@ describe('workflow library view cache', () => { expect(libraryCache.getLibraryWorkflowCached).not.toHaveBeenCalled(); }); + it('keeps shared pages and tag counts separate from Yours in the view cache', async () => { + api.listLibraryWorkflows.mockImplementation(({ isPublic }: { isPublic?: boolean }) => + Promise.resolve(buildPage([buildItem(isPublic ? 'shared' : 'yours')])) + ); + api.getAllWorkflowTags.mockImplementation(({ isPublic }: { isPublic?: boolean }) => + Promise.resolve([isPublic ? 'shared-tag' : 'yours-tag']) + ); + api.getWorkflowTagCounts.mockImplementation(({ tags, isPublic }: { tags: string[]; isPublic?: boolean }) => + Promise.resolve({ [tags[0] ?? '']: isPublic ? 2 : 1 }) + ); + + await browse.ensureWorkflowLibraryBrowseLoaded(); + await vi.waitFor(() => + expect(browse.getWorkflowLibraryBrowseSnapshot().tagCounts).toEqual([{ count: 1, tag: 'yours-tag' }]) + ); + const yours = browse.getWorkflowLibraryBrowseSnapshot().entries; + + browse.setWorkflowLibraryBrowseFilter({ isPublic: true }); + await vi.waitFor(() => { + expect(browse.getWorkflowLibraryBrowseSnapshot().entries[0]?.item.workflow_id).toBe('shared'); + expect(browse.getWorkflowLibraryBrowseSnapshot().tagCounts).toEqual([{ count: 2, tag: 'shared-tag' }]); + }); + const shared = browse.getWorkflowLibraryBrowseSnapshot().entries; + + api.listLibraryWorkflows.mockClear(); + browse.setWorkflowLibraryBrowseFilter({ isPublic: undefined }); + expect(browse.getWorkflowLibraryBrowseSnapshot().entries).toBe(yours); + expect(browse.getWorkflowLibraryBrowseSnapshot().tagCounts).toEqual([{ count: 1, tag: 'yours-tag' }]); + + browse.setWorkflowLibraryBrowseFilter({ isPublic: true }); + expect(browse.getWorkflowLibraryBrowseSnapshot().entries).toBe(shared); + expect(browse.getWorkflowLibraryBrowseSnapshot().tagCounts).toEqual([{ count: 2, tag: 'shared-tag' }]); + await flushAsyncWork(); + expect(api.listLibraryWorkflows).not.toHaveBeenCalled(); + }); + it('keeps a stale view on screen while it revalidates, then merges without re-reading unchanged rows', async () => { const yours = await browseYoursThenBrowse(); const later = Date.now() + 60_000; diff --git a/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.ts b/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.ts index 943a36797ef..546c4e9446d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.ts @@ -38,6 +38,8 @@ export type { WorkflowTagCount } from '@features/workflow/core/libraryTags'; export interface WorkflowLibraryBrowseFilter { category: WorkflowLibraryCategory; + /** Public user workflows form a distinct view; absent keeps user listings owner scoped. */ + isPublic?: boolean; tag: string | null; search: string; } @@ -63,7 +65,7 @@ export interface WorkflowLibraryBrowseSnapshot { page: number; pages: number; total: number; - /** Chip counts for the current category. */ + /** Chip counts for the current category and visibility filter. */ tagCounts: readonly WorkflowTagCount[]; /** Total user-category workflows, for the one-time Browse/Yours auto-switch. */ userTotal: number | null; @@ -95,7 +97,7 @@ const store = createExternalStore(INITIAL_SNAPSHO // #region View cache /** - * Views already browsed (Browse, Yours, a tag or search), kept so switching back shows them at once with their + * Views already browsed (Browse, Yours, Shared, a tag or search), kept so switching back shows them at once with their * enrichment instead of refetching and re-reading every row. A view older than `VIEW_FRESH_MS` is revalidated in the * background and merged in place; library mutations drop the other views. */ @@ -114,8 +116,8 @@ const viewCache = new Map(); /** When the current view's pages last arrived from the server. */ let currentViewFetchedAt = 0; -const getFilterKey = ({ category, search, tag }: WorkflowLibraryBrowseFilter): string => - JSON.stringify([category, search, tag]); +const getFilterKey = ({ category, isPublic, search, tag }: WorkflowLibraryBrowseFilter): string => + JSON.stringify([category, isPublic, search, tag]); const rememberCurrentView = (): void => { const { entries, filter, page, pages, status, tagCounts, total } = store.getSnapshot(); @@ -350,6 +352,7 @@ const fetchPage = ( ): Promise => listLibraryWorkflows({ category: filter.category, + ...(filter.isPublic === undefined ? {} : { isPublic: filter.isPublic }), page, perPage: PER_PAGE, query: filter.search || undefined, @@ -400,14 +403,22 @@ const loadMorePages = async (filter: WorkflowLibraryBrowseFilter, page: number, }; /** Tag chips are best-effort: a failed count leaves the previous chips in place. */ -const loadTagCounts = async (category: WorkflowLibraryCategory, owner: AccountScope): Promise => { +const loadTagCounts = async ( + category: WorkflowLibraryCategory, + isPublic: boolean | undefined, + owner: AccountScope +): Promise => { try { - const tags = await getAllWorkflowTags({ categories: [category], signal: owner.signal }); + const publicFilter = isPublic === undefined ? {} : { isPublic }; + const tags = await getAllWorkflowTags({ categories: [category], ...publicFilter, signal: owner.signal }); const counts = - tags.length > 0 ? await getWorkflowTagCounts({ categories: [category], signal: owner.signal, tags }) : {}; + tags.length > 0 + ? await getWorkflowTagCounts({ categories: [category], ...publicFilter, signal: owner.signal, tags }) + : {}; - // A category switch mid-flight owns the chips now. - if (!isAccountScopeCurrent(owner) || store.getSnapshot().filter.category !== category) { + // A category or visibility switch mid-flight owns the chips now. + const filter = store.getSnapshot().filter; + if (!isAccountScopeCurrent(owner) || filter.category !== category || filter.isPublic !== isPublic) { return; } @@ -450,6 +461,7 @@ export const setWorkflowLibraryBrowseFilter = (patch: Partial => { hasTemplateLoadFailed = false; store.patchSnapshot({ error: null, status: 'loading' }); - await Promise.all([loadFirstPage(filter, owner), loadTagCounts(filter.category, owner), probeUserTotal(owner)]); + await Promise.all([ + loadFirstPage(filter, owner), + loadTagCounts(filter.category, filter.isPublic, owner), + probeUserTotal(owner), + ]); }); }; @@ -575,7 +591,7 @@ export const refreshWorkflowLibraryBrowse = (): Promise => } } - await loadTagCounts(filter.category, owner); + await loadTagCounts(filter.category, filter.isPublic, owner); }); export const getWorkflowLibraryBrowseSnapshot = (): WorkflowLibraryBrowseSnapshot => store.getSnapshot(); diff --git a/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts index 0da4e990110..088197e35ac 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts @@ -39,6 +39,7 @@ describe('workflow publication controller', () => { getRecord: vi.fn(), invalidateCache: vi.fn(), reserveId: vi.fn(() => 'reserved-1'), + setPublic: vi.fn(), updateRecord: vi.fn(), }; @@ -49,11 +50,13 @@ describe('workflow publication controller', () => { deps.getRecord.mockReset(); deps.invalidateCache.mockReset(); deps.reserveId.mockReset().mockReturnValue('reserved-1'); + deps.setPublic.mockReset(); deps.updateRecord.mockReset(); }); - const request = (destination: Parameters[0]['destination']) => ({ + const request = (destination: Parameters[0]['destination'], isPublic?: boolean) => ({ destination, + ...(isPublic === undefined ? {} : { isPublic }), owner, projectId: 'project-1', workflow, @@ -62,7 +65,9 @@ describe('workflow publication controller', () => { const controller = createWorkflowPublicationController(deps); it('creates under a reserved id, names the template, and invalidates the library', async () => { - deps.createRecord.mockResolvedValue(record({ name: 'Named', revision: 1, workflow_id: 'reserved-1' })); + deps.createRecord.mockResolvedValue( + record({ name: 'Named', revision: 1, user_id: 'user-a', workflow_id: 'reserved-1' }) + ); const result = await controller.publish(request({ kind: 'create', name: 'Named' })); @@ -70,6 +75,7 @@ describe('workflow publication controller', () => { kind: 'created', libraryWorkflowId: 'reserved-1', name: 'Named', + ownerUserId: 'user-a', revision: 1, status: 'published', }); @@ -78,6 +84,48 @@ describe('workflow publication controller', () => { { reservedId: 'reserved-1', signal: owner.signal } ); expect(deps.invalidateCache).toHaveBeenCalledWith('reserved-1'); + expect(deps.setPublic).not.toHaveBeenCalled(); + }); + + it('invalidates the private record after partial visibility and retries under one reserved id', async () => { + deps.createRecord + .mockRejectedValueOnce(new TypeError('create answer lost')) + .mockResolvedValueOnce(record({ name: 'Named', is_public: false, workflow_id: 'reserved-1' })); + deps.setPublic + .mockRejectedValueOnce(new TypeError('visibility answer lost')) + .mockResolvedValueOnce(record({ name: 'Named', is_public: true, workflow_id: 'reserved-1' })); + + const failed = await controller.publish(request({ kind: 'create', name: 'Named' }, true)); + + expect(failed.status).toBe('failed'); + if (failed.status !== 'failed') { + throw new Error('expected the create failure'); + } + + const partial = await failed.retry(); + + expect(partial).toMatchObject({ libraryWorkflowId: 'reserved-1', status: 'partial' }); + expect(deps.createRecord).toHaveBeenCalledTimes(2); + expect(deps.setPublic).toHaveBeenCalledWith('reserved-1', true, owner.signal); + expect(deps.invalidateCache).toHaveBeenCalledTimes(1); + expect(deps.invalidateCache).toHaveBeenCalledWith('reserved-1'); + if (partial.status !== 'partial') { + throw new Error('expected the visibility failure'); + } + + await expect(partial.retry()).resolves.toMatchObject({ + kind: 'created', + libraryWorkflowId: 'reserved-1', + status: 'published', + }); + expect(deps.createRecord.mock.calls.map((call) => call[1].reservedId)).toEqual(['reserved-1', 'reserved-1']); + expect(deps.setPublic.mock.calls).toEqual([ + ['reserved-1', true, owner.signal], + ['reserved-1', true, owner.signal], + ]); + expect(deps.reserveId).toHaveBeenCalledTimes(1); + expect(deps.invalidateCache).toHaveBeenCalledTimes(2); + expect(deps.invalidateCache).toHaveBeenCalledWith('reserved-1'); }); it('refuses to overlap a publication of the same workflow and reports when it is busy', async () => { @@ -132,6 +180,7 @@ describe('workflow publication controller', () => { { expectedRevision: 2, signal: owner.signal } ); expect(deps.invalidateCache).toHaveBeenCalledWith('lib-1'); + expect(deps.setPublic).not.toHaveBeenCalled(); }); it('reports a conflict from the record read alone, without sending', async () => { diff --git a/invokeai/frontend/webv2/src/features/workflow/data/publication.ts b/invokeai/frontend/webv2/src/features/workflow/data/publication.ts index ba00852a52a..3ac118eb6ea 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/publication.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/publication.ts @@ -9,6 +9,7 @@ import type { WorkflowLibraryWriteRefusedError, WorkflowRecordDTO } from './api' import { createLibraryWorkflowRecord, getLibraryWorkflowRecord, + setLibraryWorkflowPublic, toWorkflowLibraryWriteRefusal, updateLibraryWorkflow, } from './api'; @@ -30,17 +31,33 @@ export interface WorkflowPublicationRequest { workflowId: string; /** The serialized document as it stood when the user confirmed; later edits are not part of this publication. */ workflow: Record; + /** When true, create the template privately first and then request public visibility. */ + isPublic?: boolean; destination: WorkflowPublicationDestination; } export type WorkflowPublicationResult = - | { status: 'published'; kind: 'created' | 'updated'; libraryWorkflowId: string; revision: number; name: string } + | { + status: 'published'; + kind: 'created' | 'updated'; + libraryWorkflowId: string; + ownerUserId?: string; + revision: number; + name: string; + } /** The template moved past the expected revision, or its revision was never known. */ | { status: 'conflict'; libraryWorkflowId: string; currentRevision: number | null } /** The destination cannot be written: it is gone, someone else's, or bundled. Saving as new still works. */ | { status: 'unavailable'; libraryWorkflowId: string; reason: 'missing' | 'forbidden' | 'bundled' } /** The server rejected the content itself. */ | { status: 'invalid'; message: string } + /** The template exists privately, but the requested visibility change did not settle. */ + | { + status: 'partial'; + libraryWorkflowId: string; + message: string; + retry: () => Promise; + } /** Nothing definitive came back; `retry` resends the same captured request under the same reserved id. */ | { status: 'failed'; message: string; retry: () => Promise } /** The account changed underneath the publication; nothing more will be reported. */ @@ -53,6 +70,7 @@ export interface WorkflowPublicationDeps { getRecord: typeof getLibraryWorkflowRecord; invalidateCache: typeof invalidateWorkflowLibraryCache; reserveId: () => string; + setPublic: typeof setLibraryWorkflowPublic; updateRecord: typeof updateLibraryWorkflow; } @@ -61,6 +79,7 @@ const PRODUCTION_DEPS: WorkflowPublicationDeps = { getRecord: getLibraryWorkflowRecord, invalidateCache: invalidateWorkflowLibraryCache, reserveId: createUuid, + setPublic: setLibraryWorkflowPublic, updateRecord: updateLibraryWorkflow, }; @@ -112,6 +131,7 @@ const toPublished = ( kind, libraryWorkflowId: record.workflow_id, name: record.name, + ...(record.category === 'user' && record.user_id ? { ownerUserId: record.user_id } : {}), revision: record.revision, status: 'published', }); @@ -179,7 +199,7 @@ export const createWorkflowPublicationController = ( return { status: 'cancelled' }; } - if (result.status === 'published') { + if (result.status === 'published' || result.status === 'partial') { deps.invalidateCache(result.libraryWorkflowId); } @@ -204,6 +224,28 @@ export const createWorkflowPublicationController = ( }; const create = (request: WorkflowPublicationRequest, name: string, reservedId: string) => { + const retryVisibility = (libraryWorkflowId: string): Promise => { + const attempt = (): Promise => + settle(request, async () => { + try { + return toPublished('created', await deps.setPublic(libraryWorkflowId, true, request.owner.signal)); + } catch (error) { + if (!isAccountScopeCurrent(request.owner)) { + return { status: 'cancelled' }; + } + + return { + libraryWorkflowId, + message: getApiErrorMessage(error, 'The library did not answer.'), + retry: attempt, + status: 'partial', + }; + } + }); + + return attempt(); + }; + const attempt = (): Promise => settle(request, async () => { try { @@ -214,6 +256,23 @@ export const createWorkflowPublicationController = ( { reservedId, signal: request.owner.signal } ); + if (request.isPublic === true) { + try { + return toPublished('created', await deps.setPublic(record.workflow_id, true, request.owner.signal)); + } catch (error) { + if (!isAccountScopeCurrent(request.owner)) { + return { status: 'cancelled' }; + } + + return { + libraryWorkflowId: record.workflow_id, + message: getApiErrorMessage(error, 'The library did not answer.'), + retry: () => retryVisibility(record.workflow_id), + status: 'partial', + }; + } + } + return toPublished('created', record); } catch (error) { const refusal = toWorkflowLibraryWriteRefusal(error); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx index 56319464a87..19731b05508 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx @@ -53,8 +53,10 @@ describe('PendingWorkflowLoader', () => { beforeEach(() => { addWorkflow = vi.fn(); api.getLibraryWorkflowRecord.mockReset().mockResolvedValue({ + category: 'user', name: 'Portrait', revision: 2, + user_id: 'user-a', workflow: { edges: [], name: 'Portrait', nodes: [] }, workflow_id: 'lib-portrait', }); @@ -93,7 +95,7 @@ describe('PendingWorkflowLoader', () => { await vi.waitFor(() => expect(addWorkflow).toHaveBeenCalledTimes(1)); expect(addWorkflow.mock.calls[0]?.[1]).toMatchObject({ reusePlaceholder: true, - source: { libraryWorkflowId: 'lib-portrait', revision: 2 }, + source: { libraryWorkflowId: 'lib-portrait', ownerUserId: 'user-a', revision: 2 }, }); expect(workflowUiStore.getSnapshot().libraryCopyChoice).toBeNull(); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx index 2035bfe1d38..7ec65869f05 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx @@ -52,6 +52,7 @@ export const PendingWorkflowLoader = () => { let raw: unknown = null; let label = ''; let libraryRevision: number | null = null; + let libraryOwnerUserId: string | undefined; if (source.kind === 'library') { const record = await getLibraryWorkflowRecord(source.workflowId, owner.signal); @@ -60,6 +61,7 @@ export const PendingWorkflowLoader = () => { raw = { ...record.workflow, id: record.workflow_id }; label = t('commandPalette.workflowLoad.loaded', { name }); libraryRevision = record.revision; + libraryOwnerUserId = record.category === 'user' ? record.user_id : undefined; } else { raw = source.raw; label = source.label; @@ -81,7 +83,13 @@ export const PendingWorkflowLoader = () => { // An embedded document may carry the id of the record it was saved from; that id is provenance, never // a write target. ...(source.kind === 'library' - ? { source: { libraryWorkflowId: source.workflowId, revision: libraryRevision } } + ? { + source: { + libraryWorkflowId: source.workflowId, + ...(libraryOwnerUserId ? { ownerUserId: libraryOwnerUserId } : {}), + revision: libraryRevision, + }, + } : {}), }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowMenuItems.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowMenuItems.browser.test.tsx index b753c1c81c9..278862f681a 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowMenuItems.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowMenuItems.browser.test.tsx @@ -22,6 +22,19 @@ const { copyWorkflowJsonMock, downloadWorkflowJsonMock } = vi.hoisted(() => ({ downloadWorkflowJsonMock: vi.fn(), })); +const identity = vi.hoisted(() => ({ + session: { multiuserEnabled: false, phase: 'ready', user: null } as { + multiuserEnabled: boolean; + phase: 'ready'; + user: { user_id: string } | null; + }, +})); + +vi.mock('@features/identity', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuthSession: () => identity.session, +})); + vi.mock('./workflowTransfer', () => ({ copyWorkflowJson: copyWorkflowJsonMock, downloadWorkflowJson: downloadWorkflowJsonMock, @@ -133,6 +146,7 @@ describe('WorkflowMenuItems', () => { root = createRoot(host); copyWorkflowJsonMock.mockReset(); downloadWorkflowJsonMock.mockReset(); + identity.session = { multiuserEnabled: false, phase: 'ready', user: null }; clearWorkflowPublicationIntent(); notifications = { error: vi.fn(), info: vi.fn(), success: vi.fn() }; commands = createCommands(); @@ -242,6 +256,39 @@ describe('WorkflowMenuItems', () => { kind: 'update-source', workflowId: 'workflow-1', }); + + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + await renderMenu(createProjectGraph('workflow-1'), { + libraryWorkflowId: 'lib-public-other-account', + ownerUserId: 'another-user', + revision: 4, + }); + expect(menuItem('Update library template…')).toBeUndefined(); + const saveAsNew = menuItem('Save to library…'); + expect(saveAsNew).not.toBeUndefined(); + + await act(() => saveAsNew?.click()); + + expect(workflowUiStore.getSnapshot().publicationIntent).toEqual({ + kind: 'save-as-new', + workflowId: 'workflow-1', + }); + }); + + it('matches persisted source ownership against the current account and fails closed without an owner', async () => { + const source = { libraryWorkflowId: 'lib-1', ownerUserId: 'user-a', revision: 4 }; + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + await renderMenu(createProjectGraph('workflow-1'), source); + expect(menuItem('Update library template…')).not.toBeUndefined(); + + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-b' } }; + await renderMenu(createProjectGraph('workflow-1'), source); + expect(menuItem('Update library template…')).toBeUndefined(); + expect(menuItem('Save to library…')).not.toBeUndefined(); + + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + await renderMenu(createProjectGraph('workflow-1'), { libraryWorkflowId: 'lib-1', revision: 4 }); + expect(menuItem('Update library template…')).toBeUndefined(); }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx index b61e4395b9f..cd8f3d4260e 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx @@ -1,6 +1,7 @@ import type { InvocationTemplate, XYPosition } from '@features/workflow/contracts'; import { Box, HStack, Icon, Menu, Text } from '@chakra-ui/react'; +import { useAuthSession } from '@features/identity'; import { updateLoadedWorkflowNodes } from '@features/workflow/data/templates'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { @@ -127,13 +128,14 @@ export const WorkflowWidgetLabel = ({ region }: WorkflowWidgetLabelProps) => { /** Entries contributed to the shared widget actions menu. */ export const WorkflowMenuItems = (_props: WorkflowWidgetViewProps) => { const { t } = useTranslation(); + const session = useAuthSession(); const { getProjectGraph } = useWorkflowUi(); const { widgets } = useWorkflowHostCommands(); const { createWorkflow } = useProjectGraphCommands(); const notify = useWorkflowNotifications(); const activeWorkflowId = useWorkflowProjectSelector((project) => project.activeWorkflowId); const source = useWorkflowProjectSelector((project) => project.activeWorkflow.source); - const canUpdateSource = isUpdatableSource(source); + const canUpdateSource = isUpdatableSource(source, session); const openDetailsPanel = useCallback(() => { widgets.open({ region: 'left', widgetId: 'workflow' }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx index 53f379c7a77..62533d2a127 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx @@ -7,6 +7,10 @@ import type { import type { WorkflowGraphPreviewPort, WorkflowUiAdapter } from '@features/workflow/ui/WorkflowUiContext'; import { ChakraProvider } from '@chakra-ui/react'; +import { + getUnresolvedWorkflowPublication, + getWorkflowPublicationController, +} from '@features/workflow/ui/library/useWorkflowPublication'; import { WorkflowGraphPreviewProvider, WorkflowUiProvider } from '@features/workflow/ui/WorkflowUiContext'; import { createProjectGraph } from '@features/workflow/utility'; import { system } from '@theme/system'; @@ -102,14 +106,29 @@ vi.mock('@features/workflow/react', async (importOriginal) => ({ useInvocationTemplatesSnapshot: () => templatesSnapshotRef.current, })); -const { createLibraryWorkflowRecordMock } = vi.hoisted(() => ({ +const identity = vi.hoisted(() => ({ + session: { multiuserEnabled: false, phase: 'ready', user: null } as { + multiuserEnabled: boolean; + phase: 'ready'; + user: { user_id: string } | null; + }, +})); + +vi.mock('@features/identity', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuthSession: () => identity.session, +})); + +const { createLibraryWorkflowRecordMock, setLibraryWorkflowPublicMock } = vi.hoisted(() => ({ createLibraryWorkflowRecordMock: vi.fn(), + setLibraryWorkflowPublicMock: vi.fn(), })); // The publication controller reaches the transport through the API module itself, not the queries barrel. vi.mock('@features/workflow/data/api', async (importOriginal) => ({ ...(await importOriginal>()), createLibraryWorkflowRecord: createLibraryWorkflowRecordMock, + setLibraryWorkflowPublic: setLibraryWorkflowPublicMock, })); // Supply rendered English strings without starting the HTTP-backed i18n client. @@ -157,7 +176,13 @@ const TRANSLATIONS: Record = { 'graphPreview.showNode': 'show node', 'graphPreview.thisGraph': 'This graph', 'graphPreview.title': 'Graph preview', + 'workflowLibrary.publicVisibilityHelp': 'Visible to users of this InvokeAI instance.', + 'workflowLibrary.publicVisibilityPartial': + 'The template was saved, but its public visibility could not be confirmed ({{message}}). Retry to make it public.', + 'workflowLibrary.publicWorkflow': 'Public workflow', + 'workflowLibrary.retry': 'Retry', 'workflowLibrary.saveFailed': 'Failed to save workflow', + 'workflowLibrary.saveToLibraryConfirm': 'Save', 'workflowLibrary.saved': 'Workflow saved', 'workflowLibrary.savedCreatedBody': 'Saved "{{name}}" to the library.', }; @@ -405,6 +430,8 @@ describe('GraphPreviewDialog', () => { downloads.downloadBlob.mockReset(); downloads.downloadText.mockReset(); createLibraryWorkflowRecordMock.mockReset(); + setLibraryWorkflowPublicMock.mockReset(); + identity.session = { multiuserEnabled: false, phase: 'ready', user: null }; fitViewMock.mockClear(); templatesSnapshotRef.current = TEMPLATES_SNAPSHOT; }); @@ -476,6 +503,17 @@ describe('GraphPreviewDialog', () => { }); }); + const deferred = () => { + let reject!: (reason?: unknown) => void; + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, reject, resolve }; + }; + it('renders summary rows, node count, and the live subtitle', async () => { await renderDialog(FIXTURE_SOURCE); @@ -795,12 +833,107 @@ describe('GraphPreviewDialog', () => { expect(serialized).toMatchObject({ name: 'Generate' }); // A client-reserved id makes a resend after a lost answer idempotent. expect(options).toMatchObject({ reservedId: expect.any(String) }); + expect(options).not.toHaveProperty('isPublic'); expect(workflowUiAdapter.notifications.success).toHaveBeenCalledWith( 'Workflow saved', 'Saved "Generate" to the library.' ); // A preview is not a project workflow, so nothing in the project is pointed at the new template. expect(workflowUiAdapter.commands.setWorkflowSource).not.toHaveBeenCalled(); + expect(setLibraryWorkflowPublicMock).not.toHaveBeenCalled(); + }); + + it('Open as → Save to workflow library offers the public visibility choice in multiuser mode', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + createLibraryWorkflowRecordMock.mockImplementation( + (workflow: Record, options: { reservedId?: string }) => + Promise.resolve({ + name: String(workflow.name), + revision: 1, + workflow, + workflow_id: options.reservedId, + is_public: false, + }) + ); + setLibraryWorkflowPublicMock.mockImplementation((workflowId: string) => + Promise.resolve({ name: 'Generate', revision: 1, workflow: {}, workflow_id: workflowId, is_public: true }) + ); + + await renderDialog(FIXTURE_SOURCE); + + await openAsMenu(); + await clickMenuItemWithText('Save to workflow library'); + + const checkbox = document.querySelector('input[type="checkbox"][name="isPublic"]'); + expect(checkbox).not.toBeNull(); + expect(checkbox?.checked).toBe(false); + await act(() => checkbox?.click()); + await clickButtonWithText('Save'); + await flushAsync(); + + expect(createLibraryWorkflowRecordMock).toHaveBeenCalledTimes(1); + const [, options] = createLibraryWorkflowRecordMock.mock.calls[0] ?? []; + expect(options).toMatchObject({ reservedId: expect.any(String) }); + expect(options).not.toHaveProperty('isPublic'); + expect(setLibraryWorkflowPublicMock).toHaveBeenCalledWith( + (options as { reservedId: string }).reservedId, + true, + expect.anything() + ); + expect(workflowUiAdapter.commands.setWorkflowSource).not.toHaveBeenCalled(); + }); + + it('retries a failed visibility change with the same preview template and public choice', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + createLibraryWorkflowRecordMock.mockImplementation( + (workflow: Record, options: { reservedId?: string }) => + Promise.resolve({ + name: String(workflow.name), + revision: 1, + workflow, + workflow_id: options.reservedId, + is_public: false, + }) + ); + setLibraryWorkflowPublicMock + .mockRejectedValueOnce(new Error('visibility unavailable')) + .mockImplementationOnce((workflowId: string) => + Promise.resolve({ name: 'Generate', revision: 1, workflow: {}, workflow_id: workflowId, is_public: true }) + ); + + await renderDialog(FIXTURE_SOURCE); + + await openAsMenu(); + await clickMenuItemWithText('Save to workflow library'); + const checkbox = document.querySelector('input[type="checkbox"][name="isPublic"]'); + expect(checkbox).not.toBeNull(); + await act(() => checkbox?.click()); + await clickButtonWithText('Save'); + await flushAsync(); + + const createCall = createLibraryWorkflowRecordMock.mock.calls[0]; + expect(createCall).toBeDefined(); + if (!createCall) { + throw new Error('Expected the preview workflow to be created'); + } + const reservedId = (createCall[1] as { reservedId: string }).reservedId; + expect(setLibraryWorkflowPublicMock).toHaveBeenCalledTimes(1); + expect(setLibraryWorkflowPublicMock).toHaveBeenCalledWith(reservedId, true, expect.anything()); + expect(createCall[1]).not.toHaveProperty('isPublic'); + expect([...document.querySelectorAll('button')].some((button) => button.textContent?.includes('Retry'))).toBe(true); + expect(document.body.textContent).toContain( + 'The template was saved, but its public visibility could not be confirmed (visibility unavailable). Retry to make it public.' + ); + + await clickButtonWithText('Retry'); + await flushAsync(); + + expect(createLibraryWorkflowRecordMock).toHaveBeenCalledTimes(1); + expect(setLibraryWorkflowPublicMock.mock.calls.map((call) => call.slice(0, 2))).toEqual([ + [reservedId, true], + [reservedId, true], + ]); + expect(workflowUiAdapter.commands.setWorkflowSource).not.toHaveBeenCalled(); }); it('Open as → Save to workflow library retries a lost answer once under the same reserved id, never claiming success', async () => { @@ -843,6 +976,134 @@ describe('GraphPreviewDialog', () => { expect(item?.getAttribute('aria-disabled')).toBe('true'); }); + it('disables Fork into new project until invocation templates load, then restores it', async () => { + templatesSnapshotRef.current = { error: null, status: 'loading', templates: {} }; + + await renderDialog(FIXTURE_SOURCE); + await openAsMenu(); + + let item = findMenuItemWithText('Fork into new project'); + expect(item?.getAttribute('aria-disabled')).toBe('true'); + + templatesSnapshotRef.current = TEMPLATES_SNAPSHOT; + await renderDialog(FIXTURE_SOURCE); + + item = findMenuItemWithText('Fork into new project'); + expect(item?.getAttribute('aria-disabled')).not.toBe('true'); + await clickMenuItemWithText('Fork into new project'); + + expect(graphPreviewPort.openDocumentInNewProject).toHaveBeenCalledTimes(1); + }); + + it('discards a preview visibility failure that settles after the preview unmounts', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + const visibilityRequest = deferred>(); + const publicationController = getWorkflowPublicationController(); + const publishSpy = vi.spyOn(publicationController, 'publish'); + createLibraryWorkflowRecordMock.mockImplementation( + (_workflow: Record, options: { reservedId?: string }) => + Promise.resolve({ + name: 'Generate', + revision: 1, + workflow: {}, + workflow_id: options.reservedId, + is_public: false, + }) + ); + setLibraryWorkflowPublicMock.mockReturnValue(visibilityRequest.promise); + + await renderDialog(FIXTURE_SOURCE); + await openAsMenu(); + await clickMenuItemWithText('Save to workflow library'); + const checkbox = document.querySelector('input[type="checkbox"][name="isPublic"]'); + expect(checkbox).not.toBeNull(); + await act(() => checkbox?.click()); + await clickButtonWithText('Save'); + await flushAsync(); + + expect(setLibraryWorkflowPublicMock).toHaveBeenCalledTimes(1); + const request = publishSpy.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + if (!request) { + throw new Error('Expected the preview publication request'); + } + + await act(() => root.render(null)); + visibilityRequest.reject(new Error('visibility unavailable')); + await flushAsync(); + + expect(getUnresolvedWorkflowPublication('project-1', request.workflowId)).toBeNull(); + publishSpy.mockRestore(); + }); + + it('discards a single-user preview retry failure that settles after the preview unmounts', async () => { + const retry = deferred>(); + const publicationController = getWorkflowPublicationController(); + const publishSpy = vi.spyOn(publicationController, 'publish'); + createLibraryWorkflowRecordMock + .mockRejectedValueOnce(new Error('first attempt lost')) + .mockReturnValueOnce(retry.promise); + + await renderDialog(FIXTURE_SOURCE); + await openAsMenu(); + await clickMenuItemWithText('Save to workflow library'); + await flushAsync(); + + expect(createLibraryWorkflowRecordMock).toHaveBeenCalledTimes(2); + const request = publishSpy.mock.calls[0]?.[0]; + publishSpy.mockRestore(); + expect(request).toBeDefined(); + if (!request) { + throw new Error('Expected the preview publication request'); + } + + await act(() => root.render(null)); + retry.reject(new Error('retry unavailable')); + await flushAsync(); + + expect(getUnresolvedWorkflowPublication('project-1', request.workflowId)).toBeNull(); + }); + + it('cleans earlier unresolved single-user saves after a later preview save succeeds', async () => { + const publicationController = getWorkflowPublicationController(); + const publishSpy = vi.spyOn(publicationController, 'publish'); + createLibraryWorkflowRecordMock + .mockRejectedValueOnce(new Error('first attempt lost')) + .mockRejectedValueOnce(new Error('retry unavailable')) + .mockImplementation((workflow: Record, options: { reservedId?: string }) => + Promise.resolve({ + name: String(workflow.name), + revision: 1, + workflow, + workflow_id: options.reservedId, + is_public: true, + }) + ); + + await renderDialog(FIXTURE_SOURCE); + await openAsMenu(); + await clickMenuItemWithText('Save to workflow library'); + await flushAsync(); + + const firstRequest = publishSpy.mock.calls[0]?.[0]; + publishSpy.mockRestore(); + expect(firstRequest).toBeDefined(); + if (!firstRequest) { + throw new Error('Expected the first preview publication request'); + } + + await openAsMenu(); + await clickMenuItemWithText('Save to workflow library'); + await flushAsync(); + + expect(createLibraryWorkflowRecordMock).toHaveBeenCalledTimes(3); + expect(workflowUiAdapter.notifications.success).toHaveBeenCalledTimes(1); + + await act(() => root.render(null)); + + expect(getUnresolvedWorkflowPublication('project-1', firstRequest.workflowId)).toBeNull(); + }); + it('truncates a long resolved-input string and exposes the full value via title', async () => { await renderDialog(LONG_VALUE_SOURCE); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewOpenAsMenu.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewOpenAsMenu.tsx index ec88a083be0..17cf10c6bf1 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewOpenAsMenu.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewOpenAsMenu.tsx @@ -1,24 +1,40 @@ +import type { ProjectGraphState } from '@features/workflow/core/types'; import type { WorkflowInvocationSourceId, WorkflowPreviewGraph } from '@features/workflow/ui/contracts'; -import type { ReactNode } from 'react'; -import { Menu, Portal } from '@chakra-ui/react'; +import { Checkbox, chakra, Input, Menu, Portal, Stack, Text } from '@chakra-ui/react'; +import { useAuthSession } from '@features/identity'; import { previewGraphToDocument } from '@features/workflow/core/graphToDocument'; import { useInvocationTemplatesSnapshot } from '@features/workflow/react'; -import { useWorkflowPublication } from '@features/workflow/ui/library/useWorkflowPublication'; +import { + discardUnresolvedWorkflowPublication, + useWorkflowPublication, + type WorkflowPublicationFailure, +} from '@features/workflow/ui/library/useWorkflowPublication'; import { MenuActionItem } from '@features/workflow/ui/MenuActionItem'; import { useWorkflowGraphPreview, useWorkflowHostCommands, useWorkflowNotifications, + useWorkflowUi, } from '@features/workflow/ui/WorkflowUiContext'; import { downloadText } from '@platform/browser/downloadBlob'; +import { useMountEffect } from '@platform/react/useMountEffect'; +import { Button, CloseButton } from '@platform/ui/Button'; +import { Dialog } from '@platform/ui/Dialog'; +import { Field } from '@platform/ui/Field'; import { MenuContent } from '@platform/ui/Menu'; import { BookmarkIcon, DownloadIcon, GitForkIcon, PencilRulerIcon } from 'lucide-react'; -import { useCallback } from 'react'; +import { useCallback, useRef, useState, type FormEvent, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; const MENU_POSITIONING = { placement: 'top-end' } as const; +interface PreviewSaveRequest { + document: ProjectGraphState; + failure?: WorkflowPublicationFailure; + name: string; +} + interface GraphPreviewOpenAsMenuProps { children: ReactNode; graph: WorkflowPreviewGraph; @@ -40,10 +56,17 @@ export const GraphPreviewOpenAsMenu = ({ onClose, }: GraphPreviewOpenAsMenuProps) => { const { t } = useTranslation(); + const session = useAuthSession(); const { workflows } = useWorkflowHostCommands(); + const { project } = useWorkflowUi(); const graphPreview = useWorkflowGraphPreview(); const notifications = useWorkflowNotifications(); - const { saveDocumentAsNew } = useWorkflowPublication(); + const { retry: retryPublication, saveDocumentAsNew } = useWorkflowPublication(); + const [saveRequest, setSaveRequest] = useState(null); + const [isSavePending, setIsSavePending] = useState(false); + const activePublicationRef = useRef<{ projectId: string; workflowId: string } | null>(null); + const singleUserPublicationTargetsRef = useRef(new Set<{ projectId: string; workflowId: string }>()); + const isMountedRef = useRef(false); // A hook (not `getInvocationTemplatesSnapshot()`) so the "Edit in workflow // editor" item's disabled state updates live if the menu is opened while // templates are still loading. @@ -51,6 +74,24 @@ export const GraphPreviewOpenAsMenu = ({ const canEditInEditor = sourceId !== 'workflow'; const isTemplatesLoaded = templatesSnapshot.status === 'loaded'; + useMountEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + const activePublication = activePublicationRef.current; + + if (activePublication) { + discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId); + } + + singleUserPublicationTargetsRef.current.forEach(({ projectId, workflowId }) => { + discardUnresolvedWorkflowPublication(projectId, workflowId); + }); + singleUserPublicationTargetsRef.current.clear(); + }; + }); + const handleEditInEditor = useCallback(() => { const { document, skippedNodeTypes } = previewGraphToDocument(graph, templatesSnapshot.templates); @@ -87,19 +128,125 @@ export const GraphPreviewOpenAsMenu = ({ document.name = graph.label ?? sourceLabel; - // The publication reports its own outcome; a lost answer is retried by the same captured request. + if (session.multiuserEnabled) { + setSaveRequest({ document, name: document.name }); + return; + } + + // Single-user saves keep the backend's public-by-default behavior and retry a lost answer once. + const activePublication = { projectId: project.getSnapshot().id, workflowId: document.id }; + singleUserPublicationTargetsRef.current.add(activePublication); const result = await saveDocumentAsNew(document, document.name); + if (!isMountedRef.current) { + discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId); + singleUserPublicationTargetsRef.current.delete(activePublication); + return; + } + if (result.status === 'failed') { const retried = await result.retry(); + if (!isMountedRef.current) { + discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId); + singleUserPublicationTargetsRef.current.delete(activePublication); + return; + } + if (retried.status === 'failed') { notifications.error(t('graphPreview.saveToLibraryFailed'), retried.message); + } else { + singleUserPublicationTargetsRef.current.delete(activePublication); } + } else { + singleUserPublicationTargetsRef.current.delete(activePublication); } - }, [graph, templatesSnapshot, sourceLabel, saveDocumentAsNew, notifications, t]); + }, [graph, templatesSnapshot, sourceLabel, saveDocumentAsNew, notifications, project, session.multiuserEnabled, t]); const handleSaveToLibrary = useCallback(() => void saveToLibrary(), [saveToLibrary]); + const closeSaveDialog = useCallback(() => { + const activePublication = activePublicationRef.current; + + if (saveRequest?.failure && activePublication) { + discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId); + } + + activePublicationRef.current = null; + setSaveRequest(null); + }, [saveRequest]); + + const handleSaveSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + if (!saveRequest) { + return; + } + + const formData = new FormData(event.currentTarget); + const name = formData.get('templateName')?.toString().trim() || t('workflowLibrary.untitled'); + const isPublic = formData.has('isPublic'); + const activePublication = { projectId: project.getSnapshot().id, workflowId: saveRequest.document.id }; + + activePublicationRef.current = activePublication; + setIsSavePending(true); + try { + const result = await saveDocumentAsNew(saveRequest.document, name, isPublic); + + if (!isMountedRef.current) { + discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId); + return; + } + + if (result.status === 'failed' || result.status === 'partial') { + setSaveRequest({ ...saveRequest, failure: result, name }); + } else { + activePublicationRef.current = null; + setSaveRequest(null); + } + } finally { + if (isMountedRef.current) { + setIsSavePending(false); + } + } + }, + [project, saveDocumentAsNew, saveRequest, t] + ); + + const handleSaveRetry = useCallback(async () => { + if (!saveRequest?.failure) { + return; + } + + const activePublication = activePublicationRef.current; + setIsSavePending(true); + try { + const result = await retryPublication(saveRequest.failure); + + if (!isMountedRef.current) { + if (activePublication) { + discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId); + } + return; + } + + if (result.status === 'failed' || result.status === 'partial') { + setSaveRequest({ ...saveRequest, failure: result }); + } else { + activePublicationRef.current = null; + setSaveRequest(null); + } + } finally { + if (isMountedRef.current) { + setIsSavePending(false); + } + } + }, [retryPublication, saveRequest]); + + const handleSaveDialogOpenChange = useCallback( + (event: { open: boolean }) => (event.open ? undefined : closeSaveDialog()), + [closeSaveDialog] + ); + const handleForkIntoProject = useCallback(() => { const { document, skippedNodeTypes } = previewGraphToDocument(graph, templatesSnapshot.templates); @@ -128,47 +275,122 @@ export const GraphPreviewOpenAsMenu = ({ }, [graph]); return ( - - {children} - - - - {canEditInEditor ? ( + <> + + {children} + + + + {canEditInEditor ? ( + + ) : null} - ) : null} - - - - - - - + + + + + + + + + + + + + + + {t('workflowLibrary.saveToLibraryTitle')} + + + {saveRequest?.failure ? ( + + {saveRequest.failure.status === 'partial' + ? t('workflowLibrary.publicVisibilityPartial', { message: saveRequest.failure.message }) + : t('workflowLibrary.retryBody', { + message: saveRequest.failure.message, + name: saveRequest.name, + })} + + ) : ( + + + + + + {t('workflowLibrary.saveToLibraryExplanation')} + + + + + + + {t('workflowLibrary.publicWorkflow')} + + {t('workflowLibrary.publicVisibilityHelp')} + + + + + + )} + + + + {saveRequest?.failure ? ( + + ) : ( + + )} + + + + + + + + + + ); }; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx index a06fc909d1a..dc88a920c72 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx @@ -1,6 +1,7 @@ import type { ProjectWorkflowEntry } from '@features/workflow/core/types'; import { Badge, Box, Flex, HStack, Icon, Image, Menu, Portal, Stack, Text } from '@chakra-ui/react'; +import { useAuthSession } from '@features/identity'; import { useInvocationTemplatesSnapshot } from '@features/workflow/react'; import { MenuActionItem } from '@features/workflow/ui/MenuActionItem'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; @@ -235,6 +236,7 @@ const ProjectWorkflowDetailPanel = ({ onRename, }: ProjectWorkflowDetailPanelProps) => { const { t } = useTranslation(); + const session = useAuthSession(); const deps = useModelRequirementDeps(); const templatesSnapshot = useInvocationTemplatesSnapshot(); const openAddModels = useOpenAddModels(); @@ -322,7 +324,7 @@ const ProjectWorkflowDetailPanel = ({ const showThumbnail = Boolean(thumbnailUrl) && thumbnailUrl !== failedThumbnailUrl; const lastRun = entry.lastRun ? formatRelativeTime(entry.lastRun.completedAt, new Date()) : ''; const source = entry.source; - const canUpdateSource = isUpdatableSource(source); + const canUpdateSource = isUpdatableSource(source, session); const actionItems = ( <> diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx index d6cc71de5e5..66614382e35 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx @@ -73,6 +73,8 @@ export interface WorkflowLibraryCardProps { /** Which of the tile's controls has the actions menu open, if any; that control names the menu for the dialog. */ menuOpenedBy?: 'card' | 'button' | null; isSelected: boolean; + /** Project workflows reuse this card, but have no library visibility state. */ + showVisibilityStatus?: boolean; /** Models this workflow needs that are not installed; 0 hides the badge. */ missingCount: number; onContextMenu: (workflowId: string, anchor: WorkflowCardMenuAnchor) => void; @@ -89,6 +91,7 @@ export const WorkflowLibraryCard = ({ onContextMenu, onOpen, onSelect, + showVisibilityStatus = false, }: WorkflowLibraryCardProps) => { const { t } = useTranslation(); const [hasThumbnailFailed, setHasThumbnailFailed] = useState(false); @@ -174,6 +177,15 @@ export const WorkflowLibraryCard = ({ + {showVisibilityStatus && item.category === 'user' ? ( + + {t(item.is_public === true ? 'workflowLibrary.public' : 'workflowLibrary.private')} + + ) : null} {isActive ? ( {t('workflowLibrary.activeWorkflow')} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx index b126222e8c7..f0580a111e9 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx @@ -15,6 +15,7 @@ import { system } from '@theme/system'; import { act, StrictMode, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; import { loadGraphPreview } from './libraryPreviewSource'; import { WorkflowLibraryDetailPanel } from './WorkflowLibraryDetailPanel'; @@ -77,6 +78,9 @@ const queries = vi.hoisted(() => ({ ), invalidateWorkflowLibraryCache: vi.fn(), setLibraryWorkflowThumbnail: vi.fn((_workflowId: string, _image: Blob, _signal?: AbortSignal) => Promise.resolve()), + setLibraryWorkflowPublic: vi.fn((_workflowId: string, isPublic: boolean, _signal?: AbortSignal) => + Promise.resolve({ is_public: isPublic }) + ), updateLibraryWorkflow: vi.fn( (_workflowId: string, _workflow: Record, _options?: { expectedRevision?: number }) => Promise.resolve({} as Record) @@ -183,6 +187,11 @@ const TRANSLATIONS: Record = { 'workflowLibrary.requires': 'Requires', 'workflowLibrary.sampleOutput': 'Sample output', 'workflowLibrary.untitled': 'Untitled Workflow', + 'workflowLibrary.private': 'Private', + 'workflowLibrary.public': 'Public', + 'workflowLibrary.publicVisibilityHelp': 'Visible to users of this InvokeAI instance.', + 'workflowLibrary.publicWorkflow': 'Public workflow', + 'workflowLibrary.visibilityUpdateFailed': 'Failed to update workflow visibility', }; const interpolate = (template: string, options?: Record): string => @@ -364,7 +373,11 @@ describe('WorkflowLibraryDetailPanel', () => { setTimeout(resolve, 0); }); - const panelTree = (selected: WorkflowLibraryEntry | null, projectWorkflows: readonly ProjectWorkflowEntry[]) => ( + const panelTree = ( + selected: WorkflowLibraryEntry | null, + projectWorkflows: readonly ProjectWorkflowEntry[], + canChangeVisibility = false + ) => ( @@ -374,6 +387,7 @@ describe('WorkflowLibraryDetailPanel', () => { contextMenuPoint={null} contextMenuTriggerId={null} entry={selected} + canChangeVisibility={canChangeVisibility} projectWorkflows={projectWorkflows} onClose={onClose} onContextMenuClose={NOOP} @@ -391,10 +405,11 @@ describe('WorkflowLibraryDetailPanel', () => { const renderPanel = async ( selected: WorkflowLibraryEntry | null, - projectWorkflows: readonly ProjectWorkflowEntry[] = NO_COPIES + projectWorkflows: readonly ProjectWorkflowEntry[] = NO_COPIES, + canChangeVisibility = false ) => { await act(async () => { - root.render(panelTree(selected, projectWorkflows)); + root.render(panelTree(selected, projectWorkflows, canChangeVisibility)); await settleFrame(); }); }; @@ -472,6 +487,9 @@ describe('WorkflowLibraryDetailPanel', () => { queries.invalidateWorkflowLibraryCache.mockClear(); queries.setLibraryWorkflowThumbnail.mockReset().mockResolvedValue(undefined); queries.deleteLibraryWorkflowThumbnail.mockReset().mockResolvedValue(undefined); + queries.setLibraryWorkflowPublic + .mockReset() + .mockImplementation((_workflowId, isPublic) => Promise.resolve({ is_public: isPublic })); downloadText.mockClear(); OPEN_DOCUMENT_IN_NEW_PROJECT.mockClear(); @@ -500,6 +518,102 @@ describe('WorkflowLibraryDetailPanel', () => { expect(models.ensureStartersLoaded).toHaveBeenCalled(); }); + it('changes visibility by keyboard, stays server-driven while pending, then reflects the response and invalidates', async () => { + let resolveChange: (record: { is_public: boolean }) => void = NOOP; + queries.setLibraryWorkflowPublic.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveChange = resolve; + }) + ); + const privateEntry = { + ...TEXT_TO_IMAGE, + item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' }, + }; + + await renderPanel(privateEntry, NO_COPIES, true); + + const control = panel()?.querySelector('input[type="checkbox"]'); + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(false); + expect(control?.labels?.[0]?.textContent).toBe('Public workflow'); + expect(panel()?.textContent).toContain('Private'); + expect(panel()?.textContent).toContain('Visible to users of this InvokeAI instance.'); + + await act(async () => { + control?.focus(); + await userEvent.keyboard(' '); + await settleFrame(); + }); + + expect(queries.setLibraryWorkflowPublic).toHaveBeenCalledWith('wf-text-to-image', true, expect.anything()); + expect(control?.disabled).toBe(true); + expect(control?.checked).toBe(false); + expect(panel()?.textContent).toContain('Private'); + expect(queries.invalidateWorkflowLibraryCache).not.toHaveBeenCalled(); + + await act(async () => { + resolveChange({ is_public: true }); + await settleFrame(); + }); + + const updatedControl = panel()?.querySelector('input[type="checkbox"]'); + expect(updatedControl?.disabled).toBe(false); + expect(updatedControl?.checked).toBe(true); + expect(document.activeElement).toBe(updatedControl); + expect(panel()?.textContent).toContain('Public'); + expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image'); + expect(queries.updateLibraryWorkflow).not.toHaveBeenCalled(); + }); + + it('keeps the private server state and shows an error when visibility cannot be changed', async () => { + queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Permission denied')); + const privateEntry = { + ...TEXT_TO_IMAGE, + item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' }, + }; + + await renderPanel(privateEntry, NO_COPIES, true); + const control = panel()?.querySelector('input[type="checkbox"]'); + + await act(async () => { + control?.focus(); + await userEvent.keyboard(' '); + await settleFrame(); + }); + + expect(control?.checked).toBe(false); + expect(control?.disabled).toBe(false); + expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied'); + expect(queries.invalidateWorkflowLibraryCache).not.toHaveBeenCalled(); + }); + + it('returns a public workflow to Private and invalidates its library caches', async () => { + queries.setLibraryWorkflowPublic.mockResolvedValueOnce({ is_public: false }); + const publicEntry = { + ...TEXT_TO_IMAGE, + item: { ...TEXT_TO_IMAGE.item, is_public: true, user_id: 'user-a' }, + }; + + await renderPanel(publicEntry, NO_COPIES, true); + const control = panel()?.querySelector('input[type="checkbox"]'); + expect(control?.checked).toBe(true); + + await act(async () => { + control?.focus(); + await userEvent.keyboard(' '); + await settleFrame(); + }); + await vi.waitFor(() => + expect(queries.setLibraryWorkflowPublic).toHaveBeenCalledWith('wf-text-to-image', false, expect.anything()) + ); + + const updatedControl = panel()?.querySelector('input[type="checkbox"]'); + expect(updatedControl?.checked).toBe(false); + expect(document.activeElement).toBe(updatedControl); + expect(panel()?.textContent).toContain('Private'); + expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image'); + }); + it('offers Open as the primary action when every requirement is installed', async () => { await renderPanel(TEXT_TO_IMAGE); @@ -738,6 +852,22 @@ describe('WorkflowLibraryDetailPanel', () => { expect(queries.createLibraryWorkflow).not.toHaveBeenCalled(); }); + it('carries stable ownership when forking a Shared public workflow', async () => { + queries.getLibraryWorkflowRecordCached.mockResolvedValueOnce({ + ...RAW_RECORD, + category: 'user', + is_public: true, + user_id: 'owner-a', + }); + await renderPanel(IMAGE_TO_VIDEO); + + await clickMenuItem('fork-into-project'); + + const [, , source] = OPEN_DOCUMENT_IN_NEW_PROJECT.mock.calls[0] as [unknown, string, unknown]; + + expect(source).toEqual({ libraryWorkflowId: 'wf-image-to-video', ownerUserId: 'owner-a', revision: 7 }); + }); + it('downloads the raw workflow JSON under a slugged file name', async () => { await renderPanel(IMAGE_TO_VIDEO); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx index 92476157a73..b2ca86b97c8 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx @@ -3,7 +3,7 @@ import type { ProjectWorkflowEntry } from '@features/workflow/core/types'; import type { WorkflowLibraryEntry } from '@features/workflow/data/libraryBrowseStore'; import type { WorkflowLibraryListItem } from '@features/workflow/queries'; -import { Badge, Box, HStack, Menu, Portal, Stack, Text } from '@chakra-ui/react'; +import { Badge, Box, HStack, Menu, Portal, Stack, Switch, Text } from '@chakra-ui/react'; import { getStarterModelInstallSources, useInstallActions } from '@features/models'; import { createLibraryWorkflow, @@ -12,6 +12,7 @@ import { getLibraryWorkflowRecord, getLibraryWorkflowRecordCached, invalidateWorkflowLibraryCache, + setLibraryWorkflowPublic, updateLibraryWorkflow, } from '@features/workflow/queries'; import { MenuActionItem } from '@features/workflow/ui/MenuActionItem'; @@ -39,7 +40,7 @@ import { useTooltipTriggerIds, } from '@platform/ui'; import { CopyIcon, DownloadIcon, EllipsisIcon, GitForkIcon, PencilIcon, Trash2Icon, WorkflowIcon } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { planLibraryWorkflowOpen } from './useOpenLibraryWorkflow'; @@ -66,7 +67,11 @@ export interface WorkflowLibraryDetailPanelProps { contextMenuPoint: WorkflowCardMenuAnchor | null; /** What opened the menu last, kept through its close so focus returns there. */ contextMenuTriggerId: string | null; + /** Allows the authenticated owner to change visibility, only on Yours. */ + canChangeVisibility?: boolean; entry: WorkflowLibraryEntry | null; + /** Hides writes to a public template owned by another account. */ + isReadOnly?: boolean; /** The shell closes the library when a fork takes the user to a new project. */ onClose: () => void; onContextMenuClose: () => void; @@ -83,9 +88,11 @@ export interface WorkflowLibraryDetailPanelProps { const toFileSlug = (name: string): string => name.trim().replaceAll(/\s+/g, '-').toLowerCase() || 'workflow'; export const WorkflowLibraryDetailPanel = ({ + canChangeVisibility = false, contextMenuPoint, contextMenuTriggerId, entry, + isReadOnly = false, onClose, onContextMenuClose, onDeleted, @@ -105,6 +112,16 @@ export const WorkflowLibraryDetailPanel = ({ // Guard duplicate creation until the copy exists, including copies landing outside the visible category. const [isDuplicatePending, setIsDuplicatePending] = useState(false); const isDuplicatePendingRef = useRef(false); + const [visibilityRequest, setVisibilityRequest] = useState< + { workflowId: string; status: 'pending' } | { workflowId: string; message: string; status: 'error' } | null + >(null); + const [visibilityOverride, setVisibilityOverride] = useState<{ + workflowId: string; + isPublic: boolean; + previousItem: WorkflowLibraryListItem; + } | null>(null); + const visibilityInputRef = useRef(null); + const restoreVisibilityFocus = useRef(false); const enrichment = entry?.enrichment ?? null; // Shares the grid's per-entry cache, so selecting a card the badges already @@ -235,7 +252,7 @@ export const WorkflowLibraryDetailPanel = ({ // A rename is a content write at the record's current revision: the live record, never a cached one, is the base. const submitRename = useCallback( async (nextName: string) => { - if (!entry) { + if (!entry || isReadOnly) { return; } @@ -263,7 +280,7 @@ export const WorkflowLibraryDetailPanel = ({ throw error; } }, - [entry, notify, t] + [entry, isReadOnly, notify, t] ); const fork = useCallback(async () => { @@ -287,6 +304,7 @@ export const WorkflowLibraryDetailPanel = ({ // the library was opened from is left exactly as it was. openDocumentInNewProject(document, entry.item.name, { libraryWorkflowId: record.workflow_id, + ...(record.category === 'user' && record.user_id ? { ownerUserId: record.user_id } : {}), revision: record.revision, }); onClose(); @@ -322,10 +340,10 @@ export const WorkflowLibraryDetailPanel = ({ const handleDownload = useCallback(() => void download(), [download]); const openDeleteConfirm = useCallback(() => { - if (entry) { + if (entry && !isReadOnly) { setDeleteTargetName(entry.item.name || t('workflowLibrary.untitled')); } - }, [entry, t]); + }, [entry, isReadOnly, t]); const closeDeleteConfirm = useCallback(() => setDeleteTargetName(null), []); // A pointer point is a fixed rect; the tile button is the menu's trigger, so the menu follows it as it scrolls. @@ -361,7 +379,7 @@ export const WorkflowLibraryDetailPanel = ({ const moreActionsIds = useTooltipTriggerIds(); const confirmDelete = useCallback(async () => { - if (!entry) { + if (!entry || isReadOnly) { return; } @@ -378,11 +396,69 @@ export const WorkflowLibraryDetailPanel = ({ notify.error(t('workflowLibrary.deleteFailed'), getApiErrorMessage(error, t('common.unknownError'))); } } - }, [entry, notify, onDeleted, t]); + }, [entry, isReadOnly, notify, onDeleted, t]); const item = entry?.item ?? null; const tags = entry?.tags ?? []; const name = item ? item.name || t('workflowLibrary.untitled') : ''; + const itemIsPublic = + item && visibilityOverride?.workflowId === item.workflow_id && visibilityOverride.previousItem === item + ? visibilityOverride.isPublic + : item?.is_public === true; + const isVisibilityPending = + item !== null && visibilityRequest?.workflowId === item.workflow_id && visibilityRequest.status === 'pending'; + const visibilityError = + item !== null && visibilityRequest?.workflowId === item.workflow_id && visibilityRequest.status === 'error' + ? visibilityRequest.message + : null; + useLayoutEffect(() => { + if (restoreVisibilityFocus.current && !isVisibilityPending) { + restoreVisibilityFocus.current = false; + visibilityInputRef.current?.focus(); + } + }, [isVisibilityPending, itemIsPublic]); + const updateVisibility = useCallback( + async (isPublic: boolean) => { + if (!item || !canChangeVisibility || isVisibilityPending || isPublic === itemIsPublic) { + return; + } + + const owner = captureAccountScope(); + const workflowId = item.workflow_id; + const previousItem = item; + restoreVisibilityFocus.current = document.activeElement === visibilityInputRef.current; + setVisibilityRequest({ status: 'pending', workflowId }); + + try { + const record = await setLibraryWorkflowPublic(workflowId, isPublic, owner.signal); + + assertAccountScopeCurrent(owner); + setVisibilityOverride({ isPublic: record.is_public === true, previousItem, workflowId }); + invalidateWorkflowLibraryCache(workflowId); + setVisibilityRequest(null); + } catch (error) { + if (isAccountScopeCurrent(owner)) { + setVisibilityRequest({ + message: getApiErrorMessage(error, t('common.unknownError')), + status: 'error', + workflowId, + }); + } + } finally { + setVisibilityRequest((current) => + current?.workflowId === workflowId && current.status === 'pending' ? null : current + ); + } + }, + [canChangeVisibility, isVisibilityPending, item, itemIsPublic, t] + ); + const handleVisibilityClick = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + void updateVisibility(!itemIsPublic); + }, + [itemIsPublic, updateVisibility] + ); const openLabel = hasCopies ? t('workflowLibrary.openWithEllipsis') : t('workflowLibrary.open'); // One item set behind both the rail's overflow button and a card's right-click. @@ -417,7 +493,7 @@ export const WorkflowLibraryDetailPanel = ({ value="download-json" onSelect={handleDownload} /> - {item?.category === 'user' ? ( + {item?.category === 'user' && !isReadOnly ? ( ) : null} - {item?.category === 'user' ? ( + {item?.category === 'user' && !isReadOnly ? ( // Bundled defaults are not the account's to delete. @@ -469,6 +546,42 @@ export const WorkflowLibraryDetailPanel = ({ {name} + {item.category === 'user' ? ( + + + + {t('workflowLibrary.visibility')} + + + {t(itemIsPublic ? 'workflowLibrary.public' : 'workflowLibrary.private')} + + + {canChangeVisibility ? ( + + + + + + + {t('workflowLibrary.publicWorkflow')} + + + {t('workflowLibrary.publicVisibilityHelp')} + + + ) : null} + {visibilityError ? ( + + {t('workflowLibrary.visibilityUpdateFailed')}: {visibilityError} + + ) : null} + + ) : null} + {item.description ? ( {item.description} @@ -561,7 +674,7 @@ export const WorkflowLibraryDetailPanel = ({ ({ + session: { multiuserEnabled: false, phase: 'ready', user: null } as { + multiuserEnabled: boolean; + phase: 'ready'; + user: { user_id: string } | null; + }, +})); + vi.mock('@features/workflow/react', async (importOriginal) => ({ ...(await importOriginal>()), useInvocationTemplatesSnapshot: () => TEMPLATES_SNAPSHOT, })); +vi.mock('@features/identity', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuthSession: () => identity.session, +})); + // Stub preview rendering while exposing compiled graph, hideInvoke, and close behavior owned by dialog wiring. vi.mock('@features/workflow/ui/graph-preview/GraphPreviewDialog', () => ({ GraphPreviewDialog: ({ @@ -208,6 +221,10 @@ const TRANSLATIONS: Record = { 'workflowLibrary.openHint': 'Adds a copy to this project', 'workflowLibrary.openWithEllipsis': 'Open…', 'workflowLibrary.previewGraph': 'Preview graph', + 'workflowLibrary.private': 'Private', + 'workflowLibrary.public': 'Public', + 'workflowLibrary.publicWorkflow': 'Public workflow', + 'workflowLibrary.visibilityUpdateFailed': 'Failed to update workflow visibility', 'workflowLibrary.projectWorkflowCount_one': '{{count}} workflow in this project', 'workflowLibrary.projectWorkflowCount_other': '{{count}} workflows in this project', 'workflowLibrary.remove': 'Remove', @@ -226,6 +243,7 @@ const TRANSLATIONS: Record = { 'workflowLibrary.sourceBundled': 'From a bundled template (read-only)', 'workflowLibrary.sourceNone': 'Not linked to a library template', 'workflowLibrary.sourceRevision': 'Linked to library template at revision {{revision}}', + 'workflowLibrary.shared': 'Shared', 'workflowLibrary.thisProject': 'This project', 'workflowLibrary.thisProjectHint': 'Workflows saved with this project. Edits autosave here; the library changes only when you save to it.', @@ -293,6 +311,9 @@ const PORTRAIT = entry('wf-portrait', 'Portrait Studio', readyEnrichment(12, 'sd tags: ['portrait'], thumbnailUrl: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', }); +const NON_OWNER_SHARED = { ...PORTRAIT, item: { ...PORTRAIT.item, user_id: 'user-b' } }; +const OWNER_PRIVATE = { ...PORTRAIT, item: { ...PORTRAIT.item, is_public: false, user_id: 'user-a' } }; +const OWNER_PUBLIC = { ...PORTRAIT, item: { ...PORTRAIT.item, is_public: true, user_id: 'user-a' } }; // One absent requirement with a matching starter should show one model to install. const LANDSCAPE = entry( 'wf-landscape', @@ -370,7 +391,7 @@ const BUNDLED_COPY: ProjectWorkflowEntry = { }; const USER_COPY: ProjectWorkflowEntry = { document: { ...PREVIEW_DOCUMENT, id: 'wf-user', name: 'User copy' }, - source: { libraryWorkflowId: 'wf-portrait', revision: 4 }, + source: { libraryWorkflowId: 'wf-portrait', ownerUserId: 'user-a', revision: 4 }, }; const PROJECT_WORKFLOWS: readonly ProjectWorkflowEntry[] = [ALPHA, BUNDLED_COPY, USER_COPY]; @@ -539,6 +560,7 @@ describe('WorkflowLibraryDialog', () => { document.body.append(host); root = createRoot(host); onOpenChange = vi.fn((_isOpen: boolean) => {}); + identity.session = { multiuserEnabled: false, phase: 'ready', user: null }; browse.ensureWorkflowLibraryBrowseLoaded.mockClear(); browse.loadNextWorkflowLibraryPage.mockClear(); browse.setWorkflowLibraryBrowseFilter.mockClear(); @@ -620,6 +642,21 @@ describe('WorkflowLibraryDialog', () => { expect(workflowUiStore.getSnapshot().libraryTab).toBe('default'); }); + it('keeps Shared selected when the owner has no workflows of their own', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + setWorkflowLibraryTab('shared'); + await openWith( + withSnapshot({ + filter: { category: 'user', isPublic: true, search: '', tag: null }, + userTotal: 0, + }) + ); + await wait(0); + + expect(browse.setWorkflowLibraryBrowseFilter).not.toHaveBeenCalled(); + expect(workflowUiStore.getSnapshot().libraryTab).toBe('shared'); + }); + it('leaves the category alone when the account already has workflows', async () => { await openWith(LOADED_SNAPSHOT); await wait(0); @@ -637,6 +674,151 @@ describe('WorkflowLibraryDialog', () => { expect(browse.setWorkflowLibraryBrowseFilter).toHaveBeenCalledWith({ category: 'default', tag: null }); }); + it('opens the Shared tab with the public user-workflow filter', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + await openWith(LOADED_SNAPSHOT); + + await clickSegment('shared'); + + expect(workflowUiStore.getSnapshot().libraryTab).toBe('shared'); + expect(document.querySelector('[role="tab"][id$="-tab-shared"]')?.getAttribute('aria-selected')).toBe('true'); + expect(browse.setWorkflowLibraryBrowseFilter).toHaveBeenCalledWith({ + category: 'user', + isPublic: true, + tag: null, + }); + }); + + it('hides the Shared tab when multiuser mode is disabled', async () => { + identity.session = { multiuserEnabled: false, phase: 'ready', user: null }; + await openWith(LOADED_SNAPSHOT); + + expect(document.querySelector('[role="tab"][id$="-tab-shared"]')).toBeNull(); + }); + + it('hides template editing controls for another user’s Shared workflow', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + setWorkflowLibraryTab('shared'); + await openWith( + withSnapshot({ + entries: [NON_OWNER_SHARED], + filter: { category: 'user', isPublic: true, search: '', tag: null }, + }) + ); + + const moreActions = document.querySelector('[data-workflow-detail] [aria-label="More actions"]'); + expect(moreActions).not.toBeNull(); + await act(async () => { + moreActions?.click(); + await settleFrame(); + }); + await vi.waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull()); + + expect(document.querySelector('[data-menu-item="rename"]')).toBeNull(); + expect(document.querySelector('[data-menu-item="delete"]')).toBeNull(); + expect(document.querySelector('[aria-label="workflowLibrary.thumbnailChoose"]')).toBeNull(); + expect(document.querySelector('[data-thumbnail-remove]')).toBeNull(); + }); + + it('keeps an owner’s Shared workflow editable but hides its visibility switch', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + setWorkflowLibraryTab('shared'); + await openWith( + withSnapshot({ + entries: [{ ...PORTRAIT, item: { ...PORTRAIT.item, user_id: 'user-a' } }], + filter: { category: 'user', isPublic: true, search: '', tag: null }, + }) + ); + + const moreActions = document.querySelector('[data-workflow-detail] [aria-label="More actions"]'); + expect(moreActions).not.toBeNull(); + await act(async () => { + moreActions?.click(); + await settleFrame(); + }); + await vi.waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull()); + + expect(document.querySelector('[data-menu-item="rename"]')).not.toBeNull(); + expect(document.querySelector('[data-menu-item="delete"]')).not.toBeNull(); + expect(document.querySelector('[aria-label="workflowLibrary.thumbnailChoose"]')).not.toBeNull(); + expect(document.querySelector('[data-workflow-detail] input[type="checkbox"]')).toBeNull(); + }); + + it('shows the visibility state on saved user cards and offers the switch only to the owner in Yours', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + setWorkflowLibraryTab('user'); + await openWith( + withSnapshot({ + entries: [OWNER_PRIVATE], + filter: { category: 'user', search: '', tag: null }, + }) + ); + + expect(card('wf-portrait')?.textContent).toContain('Private'); + const ownerSwitch = document.querySelector('[data-workflow-detail] input[type="checkbox"]'); + expect(ownerSwitch).not.toBeNull(); + expect(ownerSwitch?.checked).toBe(false); + expect(ownerSwitch?.labels?.[0]?.textContent).toBe('Public workflow'); + + await renderDialog(); + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-b' } }; + await openWith( + withSnapshot({ + entries: [{ ...OWNER_PRIVATE, item: { ...OWNER_PRIVATE.item, user_id: 'user-a' } }], + filter: { category: 'user', search: '', tag: null }, + }) + ); + + expect(document.querySelector('[data-workflow-detail] input[type="checkbox"]')).toBeNull(); + const moreActions = document.querySelector('[data-workflow-detail] [aria-label="More actions"]'); + await act(async () => { + moreActions?.click(); + await settleFrame(); + }); + await vi.waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull()); + expect(document.querySelector('[data-menu-item="rename"]')).toBeNull(); + expect(document.querySelector('[data-menu-item="delete"]')).toBeNull(); + expect(document.querySelector('[aria-label="workflowLibrary.thumbnailChoose"]')).toBeNull(); + }); + + it('shows public state on Shared cards without visibility controls', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + setWorkflowLibraryTab('shared'); + await openWith( + withSnapshot({ + entries: [OWNER_PUBLIC], + filter: { category: 'user', isPublic: true, search: '', tag: null }, + }) + ); + + expect(card('wf-portrait')?.textContent).toContain('Public'); + expect(document.querySelector('[data-workflow-detail] input[type="checkbox"]')).toBeNull(); + }); + + it('keeps Yours template editing available in single-user mode', async () => { + identity.session = { multiuserEnabled: false, phase: 'ready', user: null }; + setWorkflowLibraryTab('user'); + await openWith( + withSnapshot({ + entries: [PORTRAIT], + filter: { category: 'user', search: '', tag: null }, + }) + ); + + const moreActions = document.querySelector('[data-workflow-detail] [aria-label="More actions"]'); + expect(moreActions).not.toBeNull(); + await act(async () => { + moreActions?.click(); + await settleFrame(); + }); + await vi.waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull()); + + expect(document.querySelector('[data-menu-item="rename"]')).not.toBeNull(); + expect(document.querySelector('[data-menu-item="delete"]')).not.toBeNull(); + expect(document.querySelector('[aria-label="workflowLibrary.thumbnailChoose"]')).not.toBeNull(); + expect(document.querySelector('[data-workflow-detail] input[type="checkbox"]')).toBeNull(); + }); + it('selects and clears a tag from the chip row', async () => { await openWith(LOADED_SNAPSHOT); @@ -1230,6 +1412,8 @@ describe('WorkflowLibraryDialog — This project', () => { // The project view is local; no library page is requested and no search is offered. expect(browse.ensureWorkflowLibraryBrowseLoaded).not.toHaveBeenCalled(); expect(document.querySelector('input[type="search"]')).toBeNull(); + expect(document.querySelector('[data-workflow-card] [data-workflow-visibility]')).toBeNull(); + expect(document.body.textContent).not.toContain('Public workflow'); }); it('starts a fresh workflow from the header', async () => { @@ -1395,6 +1579,19 @@ describe('WorkflowLibraryDialog — This project', () => { }); }); + it('keeps a public workflow copy read-only for Update Template when another account opens the project', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-b' } }; + project.setSnapshot(projectSnapshot(PROJECT_WORKFLOWS, 'wf-user')); + openWorkflowLibraryAtProjectWorkflow(PROJECT_ID, 'wf-user'); + await renderDialog({ storeBound: true }); + + expect(rail()?.dataset.projectWorkflowDetail).toBe('wf-user'); + await openRailMenu(); + + expect(menuItem('update-template')).toBeNull(); + expect(menuItem('save-to-library')).not.toBeNull(); + }); + it('previews a project workflow from its own document', async () => { await renderDialog(); await selectCard('wf-user'); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx index 3afec94e576..9ab82420f57 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx @@ -4,6 +4,7 @@ import type { WorkflowLibraryListItem } from '@features/workflow/queries'; import type { ChangeEvent } from 'react'; import { HStack, Input, Portal, Spinner, Stack, Text } from '@chakra-ui/react'; +import { useAuthSession } from '@features/identity'; import { ensureWorkflowLibraryBrowseLoaded, getWorkflowLibraryBrowseSnapshot, @@ -45,6 +46,7 @@ const TAB_ITEMS: ReadonlyArray<{ labelKey: string; value: WorkflowLibraryTab }> { labelKey: 'workflowLibrary.thisProject', value: 'project' }, { labelKey: 'workflowLibrary.browse', value: 'default' }, { labelKey: 'workflowLibrary.yours', value: 'user' }, + { labelKey: 'workflowLibrary.shared', value: 'shared' }, ]; /** Flat and shallow-comparable, so unrelated store patches do not re-render the shell. */ @@ -52,6 +54,7 @@ const selectBrowseView = (snapshot: WorkflowLibraryBrowseSnapshot) => ({ category: snapshot.filter.category, entries: snapshot.entries, error: snapshot.error, + isPublic: snapshot.filter.isPublic, status: snapshot.status, tag: snapshot.filter.tag, tagCounts: snapshot.tagCounts, @@ -71,7 +74,7 @@ const WorkflowLibraryBrowseSession = () => { void ensureWorkflowLibraryBrowseLoaded().then(() => { const { filter, userTotal } = getWorkflowLibraryBrowseSnapshot(); - if (userTotal === 0 && filter.category === 'user' && !filter.search) { + if (userTotal === 0 && filter.category === 'user' && !filter.isPublic && !filter.search) { setWorkflowLibraryBrowseFilter({ category: 'default', tag: null }); setWorkflowLibraryTab('default'); } @@ -94,8 +97,10 @@ export const WorkflowLibraryDialog = ({ onOpenChange: (isOpen: boolean) => void; }) => { const { t } = useTranslation(); - const { category, entries, error, status, tag, tagCounts } = useWorkflowLibraryBrowseSelector(selectBrowseView); + const { category, entries, error, isPublic, status, tag, tagCounts } = + useWorkflowLibraryBrowseSelector(selectBrowseView); const { selection, tab } = workflowUiStore.useSelector(selectLibraryNavigation); + const authSession = useAuthSession(); const projectId = useWorkflowProjectSelector((project) => project.id); const projectWorkflows = useWorkflowProjectSelector((project) => project.workflows); const templatesSnapshot = useInvocationTemplatesSnapshot(); @@ -128,6 +133,19 @@ export const WorkflowLibraryDialog = ({ ? selectedWorkflowId : (entries[0]?.item.workflow_id ?? null); const activeEntry = entries.find((entry) => entry.item.workflow_id === activeWorkflowId) ?? null; + const isCurrentLibraryOwner = + authSession.phase === 'ready' && + (authSession.multiuserEnabled + ? Boolean(activeEntry?.item.user_id && activeEntry.item.user_id === authSession.user?.user_id) + : true); + const canEditLibraryEntry = activeEntry?.item.category === 'user' && isCurrentLibraryOwner; + const isReadOnlyLibraryEntry = activeEntry?.item.category === 'user' && !isCurrentLibraryOwner; + const canChangeVisibility = + tab === 'user' && + authSession.phase === 'ready' && + authSession.multiuserEnabled && + activeEntry?.item.category === 'user' && + isCurrentLibraryOwner; // The project selection belongs to the project it was made in; another project starts from its active workflow. const projectSelectionId = selection?.projectId === projectId ? selection.workflowId : null; @@ -214,15 +232,16 @@ export const WorkflowLibraryDialog = ({ return; } - // The store applies filter patches literally, so clearing the tag when the - // category changes (its chips do not carry over) is the UI's job. + const nextCategory = value === 'default' ? 'default' : 'user'; + const nextIsPublic = value === 'shared' ? true : undefined; + setWorkflowLibraryTab(value); - if (category !== value) { - setWorkflowLibraryBrowseFilter({ category: value, tag: null }); + if (category !== nextCategory || isPublic !== nextIsPublic) { + setWorkflowLibraryBrowseFilter({ category: nextCategory, isPublic: nextIsPublic, tag: null }); } }, - [category] + [category, isPublic] ); const handleTagSelect = useCallback((nextTag: string | null) => setWorkflowLibraryBrowseFilter({ tag: nextTag }), []); @@ -295,8 +314,15 @@ export const WorkflowLibraryDialog = ({ const isProjectTab = tab === 'project'; const tabsIdBase = useId(); - const activeTab: WorkflowLibraryTab = isProjectTab ? 'project' : category; - const tabItems = useMemo(() => TAB_ITEMS.map((item) => ({ id: item.value, label: t(item.labelKey) })), [t]); + const activeTab: WorkflowLibraryTab = tab; + const tabItems = useMemo( + () => + TAB_ITEMS.filter((item) => item.value !== 'shared' || authSession.multiuserEnabled).map((item) => ({ + id: item.value, + label: t(item.labelKey), + })), + [authSession.multiuserEnabled, t] + ); return ( <> @@ -401,6 +427,7 @@ export const WorkflowLibraryDialog = ({ error={error} missingCounts={missingCounts} openMenuAnchor={contextMenuPoint} + showVisibilityStatus selectedWorkflowId={activeWorkflowId} status={status} onContextMenu={handleCardContextMenu} @@ -411,6 +438,8 @@ export const WorkflowLibraryDialog = ({ contextMenuPoint={contextMenuPoint} contextMenuTriggerId={contextMenuTriggerId} entry={activeEntry} + canChangeVisibility={canChangeVisibility} + isReadOnly={isReadOnlyLibraryEntry || !canEditLibraryEntry} projectWorkflows={projectWorkflows} onClose={closeDialog} onContextMenuClose={closeContextMenu} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx index b371534da1d..4fe6bee9ea4 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx @@ -21,6 +21,8 @@ export interface WorkflowLibraryGridProps { error: string | null; /** What has the actions menu open, so that tile's control can name it. */ openMenuAnchor?: WorkflowCardMenuAnchor | null; + /** Project workflows reuse the grid but have no library visibility state. */ + showVisibilityStatus?: boolean; /** Missing-model counts by workflow id; absent ids render no badge. */ missingCounts?: ReadonlyMap; selectedWorkflowId: string | null; @@ -55,6 +57,7 @@ export const WorkflowLibraryGrid = ({ error, missingCounts = NO_MISSING_COUNTS, openMenuAnchor = null, + showVisibilityStatus = false, onContextMenu, onOpen, onSelect, @@ -117,6 +120,7 @@ export const WorkflowLibraryGrid = ({ : null } missingCount={missingCounts.get(entry.item.workflow_id) ?? 0} + showVisibilityStatus={showVisibilityStatus} onContextMenu={onContextMenu} onOpen={onOpen} onSelect={onSelect} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryThumbnail.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryThumbnail.tsx index d366f1d25ae..76d09b5e9ab 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryThumbnail.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryThumbnail.tsx @@ -45,9 +45,11 @@ const WELL_CSS: SystemStyleObject = { /** Key by workflow id: an action in flight for one workflow must not show as busy on the next selection. */ export const WorkflowLibraryThumbnail = ({ + canEdit, item, workflowDocument, }: { + canEdit: boolean; /** The template's parsed workflow once its row has loaded; the snapshot renders its graph. */ workflowDocument: ProjectGraphState | null; item: WorkflowLibraryListItem; @@ -107,8 +109,7 @@ export const WorkflowLibraryThumbnail = ({ }, [t] ); - // Bundled defaults are not the account's to change; the same rule gates rename and delete. - const isEditable = item.category === 'user'; + const isEditable = canEdit; const workflowId = item.workflow_id; const handleThumbnailError = useCallback(() => setFailedThumbnailUrl(item.thumbnail_url ?? null), [item]); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx index 4dc9e0a1319..98f90dbb753 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx @@ -20,9 +20,24 @@ import { WorkflowPublicationHost } from './WorkflowPublicationHost'; * The publication controller reaches the transport through the API module itself (not the queries barrel), so * that is where the library is stubbed; the barrel re-exports the same stubs to the host's review fetch. */ +const identity = vi.hoisted(() => ({ + session: { multiuserEnabled: false, phase: 'ready', user: null } as { + multiuserEnabled: boolean; + phase: 'ready'; + user: { is_admin?: boolean; user_id: string } | null; + }, +})); + +vi.mock('@features/identity', async (importOriginal) => ({ + ...(await importOriginal>()), + getAuthSession: () => identity.session, + useAuthSession: () => identity.session, +})); + const api = vi.hoisted(() => ({ createLibraryWorkflowRecord: vi.fn(), getLibraryWorkflowRecord: vi.fn(), + setLibraryWorkflowPublic: vi.fn(), updateLibraryWorkflow: vi.fn(), })); @@ -62,12 +77,18 @@ const TRANSLATIONS: Record = { 'workflowLibrary.saveToLibraryExplanation': "Creates a new library template from this workflow. Its current input values become the template's defaults.", 'workflowLibrary.saveToLibraryTitle': 'Save to library', + 'workflowLibrary.publicVisibilityHelp': 'Visible to users of this InvokeAI instance.', + 'workflowLibrary.publicVisibilityPartial': + 'The template was saved, but its public visibility could not be confirmed ({{message}}). Retry to make it public.', + 'workflowLibrary.publicWorkflow': 'Public workflow', 'workflowLibrary.saved': 'Workflow saved', 'workflowLibrary.savedCreatedBody': 'Saved "{{name}}" to the library.', 'workflowLibrary.savedUpdatedBody': 'Updated "{{name}}" in the library.', 'workflowLibrary.templateName': 'Template name', 'workflowLibrary.unavailableBundledBody': '"{{name}}" came from a bundled template, which cannot be changed. Save it as a new template instead.', + 'workflowLibrary.unavailableForbiddenBody': + 'The library template behind "{{name}}" belongs to someone else. Save it as a new template instead.', 'workflowLibrary.unavailableTitle': 'Template cannot be updated', 'workflowLibrary.untitled': 'Untitled Workflow', 'workflowLibrary.updateConfirm': 'Update template', @@ -116,8 +137,14 @@ const WORKFLOW_ID = 'wf-1'; const LIBRARY_ID = 'lib-1'; const UNLINKED: ProjectWorkflowEntry = { document: { ...createProjectGraph(WORKFLOW_ID), name: 'Alpha' } }; -const LINKED: ProjectWorkflowEntry = { ...UNLINKED, source: { libraryWorkflowId: LIBRARY_ID, revision: 3 } }; -const LINKED_UNKNOWN: ProjectWorkflowEntry = { ...UNLINKED, source: { libraryWorkflowId: LIBRARY_ID, revision: null } }; +const LINKED: ProjectWorkflowEntry = { + ...UNLINKED, + source: { libraryWorkflowId: LIBRARY_ID, ownerUserId: 'user-a', revision: 3 }, +}; +const LINKED_UNKNOWN: ProjectWorkflowEntry = { + ...UNLINKED, + source: { libraryWorkflowId: LIBRARY_ID, ownerUserId: 'user-a', revision: null }, +}; const BUNDLED: ProjectWorkflowEntry = { ...UNLINKED, source: { libraryWorkflowId: 'default_alpha', revision: 1 } }; const OTHER: ProjectWorkflowEntry = { document: { ...createProjectGraph('wf-2'), name: 'Beta' } }; @@ -138,6 +165,7 @@ const record = (overrides: Partial = {}): WorkflowRecordDTO = name: 'Alpha', revision: 4, updated_at: new Date().toISOString(), + user_id: 'user-a', workflow: serializeWorkflowJson({ ...createProjectGraph('lib-doc'), name: 'Alpha' }), workflow_id: LIBRARY_ID, ...overrides, @@ -221,6 +249,8 @@ describe('WorkflowPublicationHost', () => { const saveDialog = () => document.querySelector('[data-save-to-library-dialog][data-state="open"]'); const reviewDialog = () => document.querySelector('[data-workflow-review-dialog][data-state="open"]'); const choice = (value: string) => document.querySelector(`[data-choice="${value}"]`); + const publicWorkflowChoice = () => + saveDialog()?.querySelector('input[type="checkbox"][name="isPublic"]') ?? null; const buttonWithText = (scope: ParentNode | null, text: string) => [...(scope?.querySelectorAll('button') ?? [])].find((candidate) => (candidate.textContent ?? '').trim() === text); @@ -234,10 +264,14 @@ describe('WorkflowPublicationHost', () => { await flush(); }; - const submitSaveAsNew = async (name: string) => { + const submitSaveAsNew = async (name: string, isPublic = false) => { const dialog = saveDialog(); expect(dialog).not.toBeNull(); + if (isPublic) { + await click(publicWorkflowChoice()); + } + const input = dialog?.querySelector('input[name="templateName"]'); const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; @@ -263,7 +297,9 @@ describe('WorkflowPublicationHost', () => { notifications = { error: vi.fn(), info: vi.fn(), success: vi.fn() }; api.createLibraryWorkflowRecord.mockReset(); api.getLibraryWorkflowRecord.mockReset(); + api.setLibraryWorkflowPublic.mockReset(); api.updateLibraryWorkflow.mockReset(); + identity.session = { multiuserEnabled: false, phase: 'ready', user: null }; // A fresh controller and an empty intent for every test. accountLifecycle.invalidate(); }); @@ -282,6 +318,7 @@ describe('WorkflowPublicationHost', () => { await request('save-as-new'); expect(saveDialog()?.querySelector('input[name="templateName"]')?.value).toBe('Alpha'); + expect(publicWorkflowChoice()).toBeNull(); expect(workflowUiStore.getSnapshot().publicationIntent).toBeNull(); await submitSaveAsNew('Alpha, published'); @@ -293,17 +330,89 @@ describe('WorkflowPublicationHost', () => { ]; expect(workflow).toMatchObject({ ...serializeWorkflowJson(UNLINKED.document), name: 'Alpha, published' }); expect(options.reservedId).toEqual(expect.any(String)); + expect(options).not.toHaveProperty('isPublic'); // The project workflow now knows which template, at which revision, it can update later. expect(commands.setWorkflowSource).toHaveBeenCalledWith( { projectId: PROJECT_ID, workflowId: WORKFLOW_ID }, - { libraryWorkflowId: 'lib-new', revision: 1 } + { libraryWorkflowId: 'lib-new', ownerUserId: 'user-a', revision: 1 } ); // It is now that template's working copy, so it takes the template's name. expect(commands.renameWorkflow).toHaveBeenCalledWith(WORKFLOW_ID, 'Alpha, published', PROJECT_ID); expect(notifications.success).toHaveBeenCalledWith('Workflow saved', 'Saved "Alpha, published" to the library.'); + expect(api.setLibraryWorkflowPublic).not.toHaveBeenCalled(); expect(saveDialog()).toBeNull(); }); + it('defaults a multiuser save to private', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + api.createLibraryWorkflowRecord.mockImplementation((workflow: Record) => + Promise.resolve(record({ name: String(workflow.name), is_public: false, workflow, workflow_id: 'lib-new' })) + ); + + await renderHost([UNLINKED]); + await request('save-as-new'); + + expect(publicWorkflowChoice()?.checked).toBe(false); + await submitSaveAsNew('Alpha, private'); + + expect(api.createLibraryWorkflowRecord).toHaveBeenCalledTimes(1); + expect(api.createLibraryWorkflowRecord.mock.calls[0]?.[1]).not.toHaveProperty('isPublic'); + expect(api.setLibraryWorkflowPublic).not.toHaveBeenCalled(); + expect(commands.setWorkflowSource).toHaveBeenCalledWith( + { projectId: PROJECT_ID, workflowId: WORKFLOW_ID }, + { libraryWorkflowId: 'lib-new', ownerUserId: 'user-a', revision: 4 } + ); + }); + + it('retries a failed public visibility update before linking the workflow', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + api.createLibraryWorkflowRecord.mockImplementation( + (workflow: Record, options: { reservedId?: string }) => + Promise.resolve( + record({ + is_public: false, + name: String(workflow.name), + revision: 4, + workflow, + workflow_id: options.reservedId, + }) + ) + ); + api.setLibraryWorkflowPublic + .mockRejectedValueOnce(new Error('visibility unavailable')) + .mockImplementationOnce((workflowId: string) => + Promise.resolve(record({ is_public: true, revision: 4, workflow_id: workflowId })) + ); + + await renderHost([UNLINKED]); + await request('save-as-new'); + + expect(publicWorkflowChoice()?.checked).toBe(false); + await submitSaveAsNew('Alpha, public', true); + + const createOptions = api.createLibraryWorkflowRecord.mock.calls[0]?.[1] as { reservedId: string }; + expect(api.createLibraryWorkflowRecord).toHaveBeenCalledTimes(1); + expect(api.setLibraryWorkflowPublic).toHaveBeenCalledTimes(1); + expect(api.setLibraryWorkflowPublic.mock.calls[0]?.slice(0, 2)).toEqual([createOptions.reservedId, true]); + expect(commands.setWorkflowSource).not.toHaveBeenCalled(); + expect(choice('retry')).not.toBeNull(); + expect(document.body.textContent).toContain( + 'The template was saved, but its public visibility could not be confirmed (visibility unavailable). Retry to make it public.' + ); + + await click(choice('retry')); + + expect(api.createLibraryWorkflowRecord).toHaveBeenCalledTimes(1); + expect(api.setLibraryWorkflowPublic.mock.calls.map((call) => call.slice(0, 2))).toEqual([ + [createOptions.reservedId, true], + [createOptions.reservedId, true], + ]); + expect(commands.setWorkflowSource).toHaveBeenCalledWith( + { projectId: PROJECT_ID, workflowId: WORKFLOW_ID }, + { libraryWorkflowId: createOptions.reservedId, ownerUserId: 'user-a', revision: 4 } + ); + }); + it('keeps a rename made while the save was in flight instead of adopting the template name', async () => { const pending = deferred(); api.createLibraryWorkflowRecord.mockReturnValueOnce(pending.promise); @@ -325,7 +434,7 @@ describe('WorkflowPublicationHost', () => { // The copy still links to the template it created; the name chosen meanwhile stands. expect(commands.setWorkflowSource).toHaveBeenCalledWith( { projectId: PROJECT_ID, workflowId: WORKFLOW_ID }, - { libraryWorkflowId: 'lib-new', revision: 1 } + { libraryWorkflowId: 'lib-new', ownerUserId: 'user-a', revision: 1 } ); expect(commands.renameWorkflow).not.toHaveBeenCalled(); expect(notifications.success).toHaveBeenCalledWith('Workflow saved', 'Saved "Alpha, published" to the library.'); @@ -366,13 +475,15 @@ describe('WorkflowPublicationHost', () => { }); it('updates the template at the revision the copy knows, after confirmation, keeping its own name', async () => { - api.getLibraryWorkflowRecord.mockResolvedValue(record({ name: 'Library alpha', revision: 3 })); + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + api.getLibraryWorkflowRecord.mockResolvedValue(record({ is_public: true, name: 'Library alpha', revision: 3 })); api.updateLibraryWorkflow.mockResolvedValue(record({ name: 'Library alpha', revision: 4 })); await renderHost([LINKED]); await request('update-source'); const dialog = dialogTitled('Update library template'); + expect(publicWorkflowChoice()).toBeNull(); await flush(); expect(dialog?.textContent).toContain('Replace the library template "Library alpha" with'); expect(api.updateLibraryWorkflow).not.toHaveBeenCalled(); @@ -390,9 +501,10 @@ describe('WorkflowPublicationHost', () => { expect(options.expectedRevision).toBe(3); expect(commands.setWorkflowSource).toHaveBeenCalledWith( { projectId: PROJECT_ID, workflowId: WORKFLOW_ID }, - { libraryWorkflowId: LIBRARY_ID, revision: 4 } + { libraryWorkflowId: LIBRARY_ID, ownerUserId: 'user-a', revision: 4 } ); expect(notifications.success).toHaveBeenCalledWith('Workflow saved', 'Updated "Library alpha" in the library.'); + expect(api.setLibraryWorkflowPublic).not.toHaveBeenCalled(); expect(dialogTitled('Update library template')).toBeNull(); }); @@ -440,7 +552,7 @@ describe('WorkflowPublicationHost', () => { expect(api.updateLibraryWorkflow.mock.calls[0]?.[2]).toMatchObject({ expectedRevision: 5 }); expect(commands.setWorkflowSource).toHaveBeenCalledWith( { projectId: PROJECT_ID, workflowId: WORKFLOW_ID }, - { libraryWorkflowId: LIBRARY_ID, revision: 6 } + { libraryWorkflowId: LIBRARY_ID, ownerUserId: 'user-a', revision: 6 } ); expect(notifications.success).toHaveBeenCalledWith('Workflow saved', 'Updated "Alpha" in the library.'); expect(dialogs()).toHaveLength(0); @@ -465,6 +577,40 @@ describe('WorkflowPublicationHost', () => { expect(api.updateLibraryWorkflow).not.toHaveBeenCalled(); }); + it('fails closed for a multiuser source with no verified owner', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } }; + await renderHost([{ ...UNLINKED, source: { libraryWorkflowId: LIBRARY_ID, revision: 3 } }]); + await request('update-source'); + + expect(dialogTitled('Template cannot be updated')?.textContent).toContain('belongs to someone else'); + expect(choice('save-as-new')).not.toBeNull(); + expect(api.updateLibraryWorkflow).not.toHaveBeenCalled(); + }); + + it('lets an admin open another account’s public workflow but only save a new template from it', async () => { + identity.session = { + multiuserEnabled: true, + phase: 'ready', + user: { is_admin: true, user_id: 'admin-user' }, + }; + const publicWorkflowFromAnotherAccount: ProjectWorkflowEntry = { + ...LINKED, + source: { ...LINKED.source!, ownerUserId: 'owner-user' }, + }; + api.getLibraryWorkflowRecord.mockResolvedValue(record({ is_public: true, revision: 3 })); + + await renderHost([publicWorkflowFromAnotherAccount]); + await request('update-source'); + + expect(dialogTitled('Template cannot be updated')?.textContent).toContain('belongs to someone else'); + expect(choice('save-as-new')).not.toBeNull(); + expect(api.updateLibraryWorkflow).not.toHaveBeenCalled(); + + await click(choice('save-as-new')); + + expect(saveDialog()).not.toBeNull(); + }); + it('offers a retry after a lost answer and resends under the same reserved id', async () => { api.createLibraryWorkflowRecord .mockRejectedValueOnce(new Error('offline')) @@ -503,7 +649,7 @@ describe('WorkflowPublicationHost', () => { expect(commands.setWorkflowSource).toHaveBeenCalledWith( { projectId: PROJECT_ID, workflowId: WORKFLOW_ID }, - { libraryWorkflowId: 'lib-new', revision: 1 } + { libraryWorkflowId: 'lib-new', ownerUserId: 'user-a', revision: 1 } ); expect(notifications.success).toHaveBeenCalledWith('Workflow saved', 'Saved "Alpha" to the library.'); }); @@ -545,7 +691,7 @@ describe('WorkflowPublicationHost', () => { expect(api.updateLibraryWorkflow).toHaveBeenCalledTimes(1); expect(commands.setWorkflowSource).toHaveBeenCalledWith( { projectId: 'project-2', workflowId: WORKFLOW_ID }, - { libraryWorkflowId: 'lib-C', revision: expect.any(Number) } + { libraryWorkflowId: 'lib-C', ownerUserId: 'user-a', revision: expect.any(Number) } ); }); @@ -643,7 +789,7 @@ describe('WorkflowPublicationHost', () => { expect(second.reservedId).toBe(first.reservedId); expect(commands.setWorkflowSource).toHaveBeenCalledWith( { projectId: PROJECT_ID, workflowId: WORKFLOW_ID }, - { libraryWorkflowId: first.reservedId, revision: 1 } + { libraryWorkflowId: first.reservedId, ownerUserId: 'user-a', revision: 1 } ); expect(dialogs()).toHaveLength(0); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx index 187b029bb03..95093e1c18e 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx @@ -2,7 +2,8 @@ import type { ProjectWorkflowEntry } from '@features/workflow/core/types'; import type { WorkflowRecordDTO } from '@features/workflow/queries'; import type { FormEvent } from 'react'; -import { chakra, Input, Portal, Stack, Text } from '@chakra-ui/react'; +import { Checkbox, chakra, Input, Portal, Stack, Text } from '@chakra-ui/react'; +import { getAuthSession, useAuthSession } from '@features/identity'; import { savedWorkflowDetailQueryOptions } from '@features/workflow/data/savedWorkflowQueries'; import { getLibraryWorkflowRecord } from '@features/workflow/queries'; import { useInvocationTemplatesSnapshot } from '@features/workflow/react'; @@ -29,7 +30,7 @@ import { DeferredGraphPreviewDialog, preloadGraphPreview, } from './libraryPreviewSource'; -import { isUpdatableSource } from './projectWorkflowEntries'; +import { isBundledLibraryWorkflowId, isUpdatableSource } from './projectWorkflowEntries'; import { formatRelativeTime } from './relativeTime'; import { discardUnresolvedWorkflowPublication, @@ -89,6 +90,7 @@ const contextOf = (stage: ActiveStage, busy = false): StageContext => ({ export const WorkflowPublicationHost = () => { const { t } = useTranslation(); + const session = useAuthSession(); const { project } = useWorkflowUi(); const workflows = useWorkflowProjectSelector((project) => project.workflows); const projectId = useWorkflowProjectSelector((project) => project.id); @@ -124,6 +126,7 @@ export const WorkflowPublicationHost = () => { const snapshot = project.getSnapshot(); const entry = snapshot.workflows.find((candidate) => candidate.document.id === intent.workflowId); + const source = entry?.source; if (!entry) { return; @@ -147,19 +150,23 @@ export const WorkflowPublicationHost = () => { if (intent.kind === 'save-as-new') { setStage({ ...context, kind: 'save-as-new' }); - } else if (isUpdatableSource(entry.source)) { + } else if (source && isUpdatableSource(source, getAuthSession())) { setStage( - entry.source.revision === null - ? { ...context, currentRevision: null, kind: 'conflict', libraryWorkflowId: entry.source.libraryWorkflowId } + source.revision === null + ? { ...context, currentRevision: null, kind: 'conflict', libraryWorkflowId: source.libraryWorkflowId } : { ...context, - expectedRevision: entry.source.revision, + expectedRevision: source.revision, kind: 'confirm-update', - libraryWorkflowId: entry.source.libraryWorkflowId, + libraryWorkflowId: source.libraryWorkflowId, } ); - } else if (entry.source) { - setStage({ ...context, kind: 'unavailable', reason: 'bundled' }); + } else if (source) { + setStage({ + ...context, + kind: 'unavailable', + reason: isBundledLibraryWorkflowId(source.libraryWorkflowId) ? 'bundled' : 'forbidden', + }); } }; let lastActiveKey = ''; @@ -220,6 +227,7 @@ export const WorkflowPublicationHost = () => { case 'unavailable': return { ...context, kind: 'unavailable', reason: result.reason }; case 'failed': + case 'partial': return { ...context, failed: result, kind: 'retry' }; default: return IDLE; @@ -234,7 +242,7 @@ export const WorkflowPublicationHost = () => { ); const handleSaveAsNew = useCallback( - async (name: string) => { + async (name: string, isPublic?: boolean) => { if (activeStage.kind === 'idle') { return; } @@ -242,7 +250,7 @@ export const WorkflowPublicationHost = () => { const { operation, workflowId } = activeStage; beginBusy(operation); - follow(operation, await publication.saveAsNew({ name, workflowId })); + follow(operation, await publication.saveAsNew({ isPublic, name, workflowId })); }, [activeStage, beginBusy, follow, publication] ); @@ -432,6 +440,7 @@ export const WorkflowPublicationHost = () => { initialName={stageEntry?.document.name ?? ''} isOpen={activeStage.kind === 'save-as-new'} isPending={isBusy} + showPublicChoice={session.multiuserEnabled} onClose={close} onSubmit={handleSaveAsNew} /> @@ -481,13 +490,19 @@ export const WorkflowPublicationHost = () => { @@ -685,25 +700,29 @@ export const SaveToLibraryDialog = ({ initialName, isOpen, isPending, + showPublicChoice, onClose, onSubmit, }: { initialName: string; isOpen: boolean; isPending: boolean; + showPublicChoice: boolean; onClose: () => void; - onSubmit: (name: string) => Promise | void; + onSubmit: (name: string, isPublic?: boolean) => Promise | void; }) => { const { t } = useTranslation(); const handleSubmit = useCallback( (event: FormEvent) => { event.preventDefault(); - const name = new FormData(event.currentTarget).get('templateName')?.toString().trim() ?? ''; + const formData = new FormData(event.currentTarget); + const name = formData.get('templateName')?.toString().trim() ?? ''; + const isPublic = showPublicChoice ? formData.has('isPublic') : undefined; - void onSubmit(name || t('workflowLibrary.untitled')); + void onSubmit(name || t('workflowLibrary.untitled'), isPublic); }, - [onSubmit, t] + [onSubmit, showPublicChoice, t] ); const handleOpenChange = useCallback((event: { open: boolean }) => (event.open ? undefined : onClose()), [onClose]); @@ -733,6 +752,20 @@ export const SaveToLibraryDialog = ({ {t('workflowLibrary.saveToLibraryExplanation')} + {showPublicChoice ? ( + + + + + + {t('workflowLibrary.publicWorkflow')} + + {t('workflowLibrary.publicVisibilityHelp')} + + + + + ) : null} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts b/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts index 15f1c429961..d052ca09629 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts @@ -1,3 +1,4 @@ +import type { AuthSession } from '@features/identity'; import type { InvocationTemplates, ProjectWorkflowEntry, ProjectWorkflowSource } from '@features/workflow/core/types'; import type { WorkflowLibraryEntry } from '@features/workflow/data/libraryBrowseStore'; @@ -25,8 +26,11 @@ export const isBundledLibraryWorkflowId = (libraryWorkflowId: string): boolean = libraryWorkflowId.startsWith('default_'); /** Whether a source can be written to at all; the server still has the final word (ownership, deletion). */ -export const isUpdatableSource = (source: ProjectWorkflowSource | undefined): source is ProjectWorkflowSource => - source !== undefined && !isBundledLibraryWorkflowId(source.libraryWorkflowId); +export const isUpdatableSource = (source: ProjectWorkflowSource | undefined, session: AuthSession): boolean => + source !== undefined && + session.phase === 'ready' && + !isBundledLibraryWorkflowId(source.libraryWorkflowId) && + (!session.multiuserEnabled || (Boolean(session.user?.user_id) && source.ownerUserId === session.user?.user_id)); export const toProjectWorkflowLibraryEntry = ( projectWorkflow: ProjectWorkflowEntry, diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.browser.test.tsx index 32db7dd13e7..9a3dfae1db9 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.browser.test.tsx @@ -14,6 +14,23 @@ const library = vi.hoisted(() => ({ getLibraryWorkflowRecordCached: vi.fn(), })); +const identity = vi.hoisted(() => ({ + session: { + multiuserEnabled: false, + phase: 'ready', + user: null, + } as { + multiuserEnabled: boolean; + phase: 'ready'; + user: { is_admin?: boolean; user_id: string } | null; + }, +})); + +vi.mock('@features/identity', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuthSession: () => identity.session, +})); + vi.mock('@features/workflow/queries', () => ({ getLibraryWorkflowRecordCached: (workflowId: string) => { library.beforeAnswer(); @@ -28,6 +45,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => k const ITEM = { name: 'Portrait', workflow_id: 'lib-portrait' }; const RECORD = { + category: 'user', name: 'Portrait', revision: 4, workflow: { @@ -74,12 +92,17 @@ describe('useOpenLibraryWorkflow replace', () => { const onOpened = vi.fn(); const Probe = () => { - const { replace } = useOpenLibraryWorkflow(onOpened); + const { open, replace } = useOpenLibraryWorkflow(onOpened); return ( - + <> + + + ); }; @@ -100,7 +123,9 @@ describe('useOpenLibraryWorkflow replace', () => { ) ); await act(async () => { - host.querySelector('button')!.click(); + [...host.querySelectorAll('button')] + .find((button) => button.textContent === 'replace')! + .click(); await vi.waitFor(() => expect(library.getLibraryWorkflowRecordCached).toHaveBeenCalled()); // The load waits two frames for the busy overlay to paint before applying. await new Promise((resolve) => { @@ -109,8 +134,34 @@ describe('useOpenLibraryWorkflow replace', () => { }); }; + const runOpen = async () => { + await act(() => + root.render( + ({ id: projectId, workflows: [] }), subscribe: () => () => {} }, + } as unknown as WorkflowUiAdapter + } + > + + + ) + ); + await act(async () => { + [...host.querySelectorAll('button')].find((button) => button.textContent === 'open')!.click(); + await vi.waitFor(() => expect(library.getLibraryWorkflowRecordCached).toHaveBeenCalled()); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + }); + }); + }; + beforeEach(() => { projectId = 'project-1'; + identity.session = { multiuserEnabled: false, phase: 'ready', user: null }; commands = { addWorkflow: vi.fn(), replaceWorkflow: vi.fn() }; notifications = { error: vi.fn(), info: vi.fn() }; onOpened.mockClear(); @@ -154,4 +205,61 @@ describe('useOpenLibraryWorkflow replace', () => { expect(notifications.info).toHaveBeenCalledWith('workflowLibrary.openProjectChanged'); expect(onOpened).not.toHaveBeenCalled(); }); + + it('opens another account’s public workflow for an admin without granting update access', async () => { + identity.session = { + multiuserEnabled: true, + phase: 'ready', + user: { is_admin: true, user_id: 'admin-user' }, + }; + library.getLibraryWorkflowRecordCached.mockResolvedValue({ + ...RECORD, + is_public: true, + user_id: 'owner-user', + }); + + await runOpen(); + + expect(commands.addWorkflow).toHaveBeenCalledTimes(1); + expect(commands.addWorkflow).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + source: { libraryWorkflowId: ITEM.workflow_id, ownerUserId: 'owner-user', revision: 4 }, + }) + ); + expect(commands.replaceWorkflow).not.toHaveBeenCalled(); + expect(onOpened).toHaveBeenCalledTimes(1); + }); + + it('keeps the update hint for a current owner of a private Library workflow', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'owner-user' } }; + library.getLibraryWorkflowRecordCached.mockResolvedValue({ + ...RECORD, + is_public: false, + user_id: 'owner-user', + }); + + await runOpen(); + + expect(commands.addWorkflow).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + source: { libraryWorkflowId: ITEM.workflow_id, ownerUserId: 'owner-user', revision: 4 }, + }) + ); + }); + + it('withholds the update hint for a multiuser Library record without a verified owner', async () => { + identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'current-user' } }; + library.getLibraryWorkflowRecordCached.mockResolvedValue({ ...RECORD, is_public: true, user_id: undefined }); + + await runOpen(); + + expect(commands.addWorkflow).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + source: { libraryWorkflowId: ITEM.workflow_id, revision: 4 }, + }) + ); + }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts index b2c3e05c2aa..f7468c042da 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts @@ -1,4 +1,4 @@ -import type { ProjectWorkflowEntry } from '@features/workflow/core/types'; +import type { ProjectWorkflowEntry, ProjectWorkflowSource } from '@features/workflow/core/types'; import type { LibraryOpenItem } from '@features/workflow/ui/workflowUiStore'; import { updateLoadedWorkflowNodes } from '@features/workflow/data/templates'; @@ -116,7 +116,11 @@ export const useOpenLibraryWorkflow = (onOpened: () => void): OpenLibraryWorkflo return; } - const source = { libraryWorkflowId: record.workflow_id, revision: record.revision }; + const source: ProjectWorkflowSource = { + libraryWorkflowId: record.workflow_id, + revision: record.revision, + ...(record.category === 'user' && record.user_id ? { ownerUserId: record.user_id } : {}), + }; if (target.kind === 'replace') { replaceWorkflow({ projectId, workflowId: target.workflowId }, document, { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/useWorkflowPublication.ts b/invokeai/frontend/webv2/src/features/workflow/ui/library/useWorkflowPublication.ts index 0ab40be7497..c27df7784fc 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/useWorkflowPublication.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/useWorkflowPublication.ts @@ -66,16 +66,24 @@ registerAccountOwnedResource({ /** A settled publication: like the controller's result, except a failed one retries through the same settlement. */ export type WorkflowPublicationOutcome = - | Exclude + | Exclude | { status: 'failed'; message: string; retry: () => Promise } + | { + status: 'partial'; + libraryWorkflowId: string; + message: string; + retry: () => Promise; + } | { status: 'rejected'; message: string }; -export type WorkflowPublicationFailure = Extract; +export type WorkflowPublicationFailure = Extract; export interface PublishProjectWorkflowOptions { workflowId: string; /** Names the new template; the project workflow keeps its own name. */ name: string; + /** Explicit visibility intent used by multiuser saves. Omitted in single-user mode. */ + isPublic?: boolean; } export interface UpdateProjectWorkflowSourceOptions { @@ -90,7 +98,11 @@ export interface WorkflowPublication { /** Replaces the template the project workflow came from, at exactly the revision the caller reviewed. */ updateSource: (options: UpdateProjectWorkflowSourceOptions) => Promise; /** Creates a template from a document that is not (yet) a project workflow, such as a compiled preview. */ - saveDocumentAsNew: (document: ProjectGraphState, name: string) => Promise; + saveDocumentAsNew: ( + document: ProjectGraphState, + name: string, + isPublic?: boolean + ) => Promise; /** Resends the captured request of a failed publication. */ retry: (result: WorkflowPublicationFailure) => Promise; isPublishing: (workflowId: string) => boolean; @@ -134,7 +146,11 @@ export const useWorkflowPublication = (): WorkflowPublication => { if (target.updateSource) { commands.setWorkflowSource( { projectId: target.projectId, workflowId: target.workflowId }, - { libraryWorkflowId: result.libraryWorkflowId, revision: result.revision } + { + libraryWorkflowId: result.libraryWorkflowId, + ...(result.ownerUserId ? { ownerUserId: result.ownerUserId } : {}), + revision: result.revision, + } ); } @@ -175,7 +191,7 @@ export const useWorkflowPublication = (): WorkflowPublication => { function settleWithRetry(result: WorkflowPublicationResult, target: PublicationTarget): WorkflowPublicationOutcome { const key = unresolvedKey(target.projectId, target.workflowId); - if (result.status === 'failed') { + if (result.status === 'failed' || result.status === 'partial') { const failure: WorkflowPublicationFailure = { ...result, retry: async () => settleWithRetry(await result.retry(), target), @@ -195,7 +211,7 @@ export const useWorkflowPublication = (): WorkflowPublication => { ); const saveAsNew = useCallback( - async ({ name, workflowId }: PublishProjectWorkflowOptions): Promise => { + async ({ isPublic, name, workflowId }: PublishProjectWorkflowOptions): Promise => { const snapshot = project.getSnapshot(); const entry = snapshot.workflows.find((candidate) => candidate.document.id === workflowId); @@ -223,6 +239,7 @@ export const useWorkflowPublication = (): WorkflowPublication => { projectId: snapshot.id, workflow: serializeWorkflowJson(entry.document), workflowId, + ...(isPublic === undefined ? {} : { isPublic }), }); return settleWithRetry(result, target); @@ -265,7 +282,7 @@ export const useWorkflowPublication = (): WorkflowPublication => { ); const saveDocumentAsNew = useCallback( - async (document: ProjectGraphState, name: string): Promise => { + async (document: ProjectGraphState, name: string, isPublic?: boolean): Promise => { const rejection = validate(document); if (rejection) { @@ -281,6 +298,7 @@ export const useWorkflowPublication = (): WorkflowPublication => { projectId, workflow: serializeWorkflowJson(document), workflowId: document.id, + ...(isPublic === undefined ? {} : { isPublic }), }); return settleWithRetry(result, target); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/workflowUiStore.ts b/invokeai/frontend/webv2/src/features/workflow/ui/workflowUiStore.ts index 60d78b07b27..1015f7c8e84 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/workflowUiStore.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/workflowUiStore.ts @@ -19,8 +19,8 @@ export type AddNodeConnectionFilter = /** Bridge transient menu actions to always-mounted workflow dialogs and file input through session UI state. */ -/** The library dialog's views: the project's own workflows, bundled templates, or the account's templates. */ -export type WorkflowLibraryTab = 'project' | 'default' | 'user'; +/** The library dialog's views: project workflows, bundled templates, owned templates, or public templates. */ +export type WorkflowLibraryTab = 'project' | 'default' | 'user' | 'shared'; /** What a surface asked the publication host to do with one project workflow. */ export type WorkflowPublicationIntent = diff --git a/invokeai/frontend/webv2/src/workbench/projectWorkflows.test.ts b/invokeai/frontend/webv2/src/workbench/projectWorkflows.test.ts index aa0464d0935..01415b42e0b 100644 --- a/invokeai/frontend/webv2/src/workbench/projectWorkflows.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projectWorkflows.test.ts @@ -198,6 +198,24 @@ describe('per-workflow edit history', () => { }); }); + it('retains verified ownership when an existing library source gains it', () => { + const unverified = setProjectWorkflowSource(session(createProjectGraph('a')), 'a', { + libraryWorkflowId: 'lib-1', + revision: 5, + }); + const verified = setProjectWorkflowSource(unverified, 'a', { + libraryWorkflowId: 'lib-1', + ownerUserId: 'owner-a', + revision: 5, + }); + + expect(verified.workflows.entries[0]?.source).toEqual({ + libraryWorkflowId: 'lib-1', + ownerUserId: 'owner-a', + revision: 5, + }); + }); + it('folds a stream of same-key edits into one step and separates them after a pause', () => { vi.useFakeTimers({ now: new Date('2026-06-10T00:00:00.000Z') }); @@ -434,6 +452,30 @@ describe('persisted collections', () => { ).toEqual({ libraryWorkflowId: 'lib', revision: null }); }); + it('preserves stable library ownership when a project collection is reopened', () => { + const good = createProjectWorkflowCollection(createProjectGraph('a')); + const reopened = normalizeProjectWorkflowCollection({ + ...good, + entries: [ + { + ...good.entries[0]!, + source: { + canUpdate: true, + libraryWorkflowId: 'lib', + ownerUserId: 'owner-a', + revision: 3, + }, + }, + ], + }); + + expect(reopened?.entries[0]?.source).toEqual({ + libraryWorkflowId: 'lib', + ownerUserId: 'owner-a', + revision: 3, + }); + }); + it('strips write provenance for portable documents', () => { const collection = createProjectWorkflowCollection(createProjectGraph('a')); diff --git a/invokeai/frontend/webv2/src/workbench/projectWorkflows.ts b/invokeai/frontend/webv2/src/workbench/projectWorkflows.ts index 9610d3b9313..fb26d4c37e9 100644 --- a/invokeai/frontend/webv2/src/workbench/projectWorkflows.ts +++ b/invokeai/frontend/webv2/src/workbench/projectWorkflows.ts @@ -113,9 +113,11 @@ const normalizeSource = (candidate: unknown): ProjectWorkflowSource | null | und } const revision = candidate.revision; + const ownerUserId = candidate.ownerUserId; return { libraryWorkflowId: candidate.libraryWorkflowId, + ...(typeof ownerUserId === 'string' && ownerUserId ? { ownerUserId } : {}), revision: Number.isSafeInteger(revision) && (revision as number) > 0 ? (revision as number) : null, }; }; @@ -386,7 +388,11 @@ export const setProjectWorkflowSource = source: ProjectWorkflowSource | undefined ): Session => replaceEntry(project, workflowId, (entry) => { - if (entry.source?.libraryWorkflowId === source?.libraryWorkflowId && entry.source?.revision === source?.revision) { + if ( + entry.source?.libraryWorkflowId === source?.libraryWorkflowId && + entry.source?.ownerUserId === source?.ownerUserId && + entry.source?.revision === source?.revision + ) { return entry; } From 53280330a77f70ee756289db4dc67cc29290f80c Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 20 Oct 2026 22:09:55 -0500 Subject: [PATCH 2/5] fix(workflow): preserve owner metadata from library records Read category from nested workflow metadata and carry the record owner through Library open and publication paths. --- .../webv2/src/features/workflow/data/api.ts | 23 +++++++++++++- .../workflow/data/libraryCache.test.ts | 17 +++++++++-- .../workflow/data/publication.test.ts | 30 +++++++++++-------- .../src/features/workflow/data/publication.ts | 3 +- .../data/savedWorkflowFieldUtils.test.ts | 29 ++++++++++++++++++ .../workflow/data/savedWorkflowFieldUtils.ts | 14 ++++----- ...dingLibraryWorkflowLoader.browser.test.tsx | 6 ++-- .../ui/PendingLibraryWorkflowLoader.tsx | 8 +++-- ...orkflowLibraryDetailPanel.browser.test.tsx | 12 +++++--- .../ui/library/WorkflowLibraryDetailPanel.tsx | 3 +- .../WorkflowPublicationHost.browser.test.tsx | 13 +++++--- .../ui/library/WorkflowPublicationHost.tsx | 6 ++-- .../useOpenLibraryWorkflow.browser.test.tsx | 26 ++++++++++++++-- .../ui/library/useOpenLibraryWorkflow.ts | 3 +- 14 files changed, 149 insertions(+), 44 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/workflow/data/api.ts b/invokeai/frontend/webv2/src/features/workflow/data/api.ts index b25e8e8e8ff..1a2904e2758 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/api.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/api.ts @@ -92,12 +92,33 @@ export const listLibraryWorkflows = ({ return apiFetchJson(`/api/v1/workflows/?${params.toString()}`, { signal }); }; -export interface WorkflowRecordDTO extends WorkflowLibraryListItem { +export interface WorkflowRecordDTO { workflow_id: string; name: string; + created_at: string; + updated_at: string; + opened_at?: string | null; + last_run_at?: string | null; + user_id: string; + is_public: boolean; + revision: number; + thumbnail_url?: string | null; + call_saved_workflow_compatibility?: WorkflowCallCompatibility | null; workflow: Record; } +export const getWorkflowRecordCategory = (record: WorkflowRecordDTO): WorkflowLibraryCategory | undefined => { + const meta = record.workflow.meta; + + if (typeof meta !== 'object' || meta === null) { + return undefined; + } + + const category = (meta as { category?: unknown }).category; + + return category === 'user' || category === 'default' ? category : undefined; +}; + export const getLibraryWorkflowRecord = (workflowId: string, signal?: AbortSignal): Promise => apiFetchJson(`/api/v1/workflows/i/${encodeURIComponent(workflowId)}`, { signal }); diff --git a/invokeai/frontend/webv2/src/features/workflow/data/libraryCache.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/libraryCache.test.ts index 4ae9f8b86de..aa24cf31de9 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/libraryCache.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/libraryCache.test.ts @@ -45,9 +45,15 @@ const buildPage = (items: WorkflowLibraryListItem[]): WorkflowLibraryPage => ({ /** Revision `n` of a workflow holds `n` notes nodes, so the graph a caller receives names its revision. */ const buildRecord = (workflowId: string, revision: number): WorkflowRecordDTO => ({ - ...buildItem(workflowId, revision), + created_at: '2026-10-20T00:00:00Z', + is_public: false, + name: `Workflow ${workflowId}`, + revision, + updated_at: '2026-10-20T00:00:00Z', + user_id: 'user-a', workflow: { edges: [], + meta: { category: 'user' }, name: `Workflow ${workflowId}`, nodes: Array.from({ length: revision }, (_unused, index) => ({ data: { label: '', notes: '' }, @@ -56,6 +62,7 @@ const buildRecord = (workflowId: string, revision: number): WorkflowRecordDTO => type: 'notes', })), }, + workflow_id: workflowId, }); const createDeferred = () => { @@ -342,13 +349,17 @@ describe('workflow library account ownership', () => { account.accountLifecycle.activate('user-b'); expect(workflowSignal.aborted).toBe(true); - delayed.resolve({ ...buildRecord('shared-id', 1), workflow: { owner: 'a' } }); + delayed.resolve({ ...buildRecord('shared-id', 1), workflow: { meta: { category: 'user' }, owner: 'a' } }); await expect(oldWorkflow).rejects.toThrow('no longer active'); - api.getLibraryWorkflowRecord.mockResolvedValueOnce({ ...buildRecord('shared-id', 1), workflow: { owner: 'b' } }); + api.getLibraryWorkflowRecord.mockResolvedValueOnce({ + ...buildRecord('shared-id', 1), + workflow: { meta: { category: 'user' }, owner: 'b' }, + }); await expect(cache.getLibraryWorkflowCached('shared-id', { expectedRevision: 1 })).resolves.toEqual({ id: 'shared-id', + meta: { category: 'user' }, owner: 'b', }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts index 088197e35ac..56ab0364bd0 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts @@ -9,17 +9,20 @@ import type { WorkflowRecordDTO } from './api'; import { WorkflowLibraryWriteRefusedError } from './api'; import { createWorkflowPublicationController, isWorkflowContentEquivalent } from './publication'; -const record = (overrides: Partial = {}): WorkflowRecordDTO => ({ - category: 'user', - description: '', - name: 'Template', - revision: 1, - workflow: { name: 'Template', nodes: [] }, - workflow_id: 'lib-1', - ...overrides, -}); - -const workflow = { name: 'Template', nodes: [{ id: 'n1' }] }; +const record = (overrides: Partial = {}): WorkflowRecordDTO => + ({ + created_at: '2026-10-20T00:00:00Z', + is_public: false, + name: 'Template', + revision: 1, + updated_at: '2026-10-20T00:00:00Z', + user_id: 'user-a', + workflow: { meta: { category: 'user' }, name: 'Template', nodes: [] }, + workflow_id: 'lib-1', + ...overrides, + }) satisfies WorkflowRecordDTO; + +const workflow = { meta: { category: 'user' }, name: 'Template', nodes: [{ id: 'n1' }] }; const deferred = () => { let resolve!: (value: T) => void; @@ -171,6 +174,7 @@ describe('workflow publication controller', () => { kind: 'updated', libraryWorkflowId: 'lib-1', name: 'Library name', + ownerUserId: 'user-a', revision: 3, status: 'published', }); @@ -251,7 +255,9 @@ describe('workflow publication controller', () => { expect(deps.updateRecord).toHaveBeenCalledTimes(2); // Someone else moved it: never overwrite. - deps.getRecord.mockResolvedValueOnce(record({ revision: 3, workflow: { name: 'Theirs', nodes: [] } })); + deps.getRecord.mockResolvedValueOnce( + record({ revision: 3, workflow: { meta: { category: 'user' }, name: 'Theirs', nodes: [] } }) + ); await expect(failed.retry()).resolves.toEqual({ currentRevision: 3, libraryWorkflowId: 'lib-1', diff --git a/invokeai/frontend/webv2/src/features/workflow/data/publication.ts b/invokeai/frontend/webv2/src/features/workflow/data/publication.ts index 3ac118eb6ea..f40e2f88f93 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/publication.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/publication.ts @@ -8,6 +8,7 @@ import type { WorkflowLibraryWriteRefusedError, WorkflowRecordDTO } from './api' import { createLibraryWorkflowRecord, + getWorkflowRecordCategory, getLibraryWorkflowRecord, setLibraryWorkflowPublic, toWorkflowLibraryWriteRefusal, @@ -131,7 +132,7 @@ const toPublished = ( kind, libraryWorkflowId: record.workflow_id, name: record.name, - ...(record.category === 'user' && record.user_id ? { ownerUserId: record.user_id } : {}), + ...(getWorkflowRecordCategory(record) === 'user' ? { ownerUserId: record.user_id } : {}), revision: record.revision, status: 'published', }); diff --git a/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.test.ts index facf0161f98..5338eeeb138 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; +import type { WorkflowRecordDTO } from './api'; + import { buildSavedWorkflowOptions, + getSavedWorkflowListItemFromRecord, getSavedWorkflowPickerOwnedQuery, getSavedWorkflowPickerSharedQuery, getSavedWorkflowSelectionState, @@ -36,6 +39,32 @@ const workflows = [ ]; describe('savedWorkflowFieldUtils', () => { + it('maps a detail record’s nested category and content into a list item', () => { + const detail = { + created_at: '2026-10-20T00:00:00Z', + is_public: false, + name: 'Bundled workflow', + revision: 3, + updated_at: '2026-10-20T00:00:00Z', + user_id: 'system', + workflow: { + description: 'A bundled workflow', + meta: { category: 'default' }, + name: 'Bundled workflow', + tags: 'starter', + }, + workflow_id: 'workflow-default', + } satisfies WorkflowRecordDTO; + + expect(getSavedWorkflowListItemFromRecord(detail)).toMatchObject({ + category: 'default', + description: 'A bundled workflow', + tags: 'starter', + user_id: 'system', + workflow_id: 'workflow-default', + }); + }); + it('builds named picker options and disables incompatible workflows', () => { expect(buildSavedWorkflowOptions(workflows)).toEqual([ { diff --git a/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.ts b/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.ts index 4411d8d23e1..31ad2db0636 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.ts @@ -2,6 +2,8 @@ import type { ComboboxOption } from '@platform/ui'; import type { ListWorkflowsParams, WorkflowCallCompatibility, WorkflowLibraryListItem, WorkflowRecordDTO } from './api'; +import { getWorkflowRecordCategory } from './api'; + export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__'; export type SavedWorkflowSelectionState = @@ -146,20 +148,14 @@ export const getSavedWorkflowDisplayState = ( /** Maps the detail endpoint's full record into the same shape as list results. */ export const getSavedWorkflowListItemFromRecord = (record: WorkflowRecordDTO): WorkflowLibraryListItem => ({ call_saved_workflow_compatibility: record.call_saved_workflow_compatibility, - category: - record.category === 'default' || - (typeof record.workflow.meta === 'object' && - record.workflow.meta !== null && - (record.workflow.meta as { category?: unknown }).category === 'default') - ? 'default' - : 'user', + category: getWorkflowRecordCategory(record) ?? 'user', created_at: record.created_at, - description: typeof record.workflow.description === 'string' ? record.workflow.description : record.description, + description: typeof record.workflow.description === 'string' ? record.workflow.description : '', is_public: record.is_public, name: record.name, opened_at: record.opened_at, revision: record.revision, - tags: typeof record.workflow.tags === 'string' ? record.workflow.tags : record.tags, + tags: typeof record.workflow.tags === 'string' ? record.workflow.tags : null, thumbnail_url: record.thumbnail_url, updated_at: record.updated_at, user_id: record.user_id, diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx index 19731b05508..1b6bf0f5a90 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx @@ -53,11 +53,13 @@ describe('PendingWorkflowLoader', () => { beforeEach(() => { addWorkflow = vi.fn(); api.getLibraryWorkflowRecord.mockReset().mockResolvedValue({ - category: 'user', + created_at: '2026-10-20T00:00:00Z', + is_public: false, name: 'Portrait', revision: 2, + updated_at: '2026-10-20T00:00:00Z', user_id: 'user-a', - workflow: { edges: [], name: 'Portrait', nodes: [] }, + workflow: { edges: [], meta: { category: 'user' }, name: 'Portrait', nodes: [] }, workflow_id: 'lib-portrait', }); workflowUiStore.patchSnapshot({ libraryCopyChoice: null, pendingWorkflowLoad: null }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx index 7ec65869f05..01b46571f93 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx @@ -1,4 +1,8 @@ -import { getLibraryWorkflowRecord, touchLibraryWorkflowOpenedAt } from '@features/workflow/data/api'; +import { + getLibraryWorkflowRecord, + getWorkflowRecordCategory, + touchLibraryWorkflowOpenedAt, +} from '@features/workflow/data/api'; import { updateLoadedWorkflowNodes } from '@features/workflow/data/templates'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { useWorkflowNotifications, useWorkflowUi } from '@features/workflow/ui/WorkflowUiContext'; @@ -61,7 +65,7 @@ export const PendingWorkflowLoader = () => { raw = { ...record.workflow, id: record.workflow_id }; label = t('commandPalette.workflowLoad.loaded', { name }); libraryRevision = record.revision; - libraryOwnerUserId = record.category === 'user' ? record.user_id : undefined; + libraryOwnerUserId = getWorkflowRecordCategory(record) === 'user' ? record.user_id : undefined; } else { raw = source.raw; label = source.label; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx index f0580a111e9..f7bc9b14350 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx @@ -331,9 +331,14 @@ const RAW_WORKFLOW: Record = { /** The library record behind IMAGE_TO_VIDEO, at the revision a fork must carry along. */ const RAW_RECORD = { - ...IMAGE_TO_VIDEO.item, + created_at: '2026-10-20T00:00:00Z', + is_public: false, + name: 'Image to video', revision: 7, - workflow: RAW_WORKFLOW, + updated_at: '2026-10-20T00:00:00Z', + user_id: 'owner-a', + workflow: { ...RAW_WORKFLOW, meta: { category: 'user', version: '3.0.0' } }, + workflow_id: 'wf-image-to-video', }; /** Project copies of TEXT_TO_IMAGE: once one exists, Open asks instead of adding another. */ @@ -846,7 +851,7 @@ describe('WorkflowLibraryDetailPanel', () => { expect(document_.name).toBe('Image to video'); expect(label).toBe('Image to video'); // The copy can update its template later only if it knows which revision it started from. - expect(source).toEqual({ libraryWorkflowId: 'wf-image-to-video', revision: 7 }); + expect(source).toEqual({ libraryWorkflowId: 'wf-image-to-video', ownerUserId: 'owner-a', revision: 7 }); // The fork lands the user in a new project, so the library gets out of the way. expect(onClose).toHaveBeenCalledTimes(1); expect(queries.createLibraryWorkflow).not.toHaveBeenCalled(); @@ -855,7 +860,6 @@ describe('WorkflowLibraryDetailPanel', () => { it('carries stable ownership when forking a Shared public workflow', async () => { queries.getLibraryWorkflowRecordCached.mockResolvedValueOnce({ ...RAW_RECORD, - category: 'user', is_public: true, user_id: 'owner-a', }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx index b2ca86b97c8..dd759047f61 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx @@ -5,6 +5,7 @@ import type { WorkflowLibraryListItem } from '@features/workflow/queries'; import { Badge, Box, HStack, Menu, Portal, Stack, Switch, Text } from '@chakra-ui/react'; import { getStarterModelInstallSources, useInstallActions } from '@features/models'; +import { getWorkflowRecordCategory } from '@features/workflow/data/api'; import { createLibraryWorkflow, deleteLibraryWorkflow, @@ -304,7 +305,7 @@ export const WorkflowLibraryDetailPanel = ({ // the library was opened from is left exactly as it was. openDocumentInNewProject(document, entry.item.name, { libraryWorkflowId: record.workflow_id, - ...(record.category === 'user' && record.user_id ? { ownerUserId: record.user_id } : {}), + ...(getWorkflowRecordCategory(record) === 'user' ? { ownerUserId: record.user_id } : {}), revision: record.revision, }); onClose(); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx index 98f90dbb753..ac779cbab31 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx @@ -160,13 +160,16 @@ const projectSnapshot = (workflows: readonly ProjectWorkflowEntry[]) => ({ }); const record = (overrides: Partial = {}): WorkflowRecordDTO => ({ - category: 'user', - description: '', + created_at: '2026-10-20T00:00:00Z', + is_public: false, name: 'Alpha', revision: 4, updated_at: new Date().toISOString(), user_id: 'user-a', - workflow: serializeWorkflowJson({ ...createProjectGraph('lib-doc'), name: 'Alpha' }), + workflow: { + ...serializeWorkflowJson({ ...createProjectGraph('lib-doc'), name: 'Alpha' }), + meta: { category: 'user', version: '3.0.0' }, + }, workflow_id: LIBRARY_ID, ...overrides, }); @@ -527,7 +530,9 @@ describe('WorkflowPublicationHost', () => { }); it('asks before writing when the copy never knew its template revision, and reviews before replacing', async () => { - api.getLibraryWorkflowRecord.mockResolvedValue(record({ description: 'Edited elsewhere', revision: 5 })); + api.getLibraryWorkflowRecord.mockResolvedValue( + record({ revision: 5, workflow: { ...record().workflow, description: 'Edited elsewhere' } }) + ); api.updateLibraryWorkflow.mockResolvedValue(record({ revision: 6 })); await renderHost([LINKED_UNKNOWN]); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx index 95093e1c18e..558ee027790 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx @@ -598,6 +598,8 @@ const ReviewBody = ({ ); } + const description = record.workflow.description; + return ( {t('workflowLibrary.reviewBody', { name: workflowName })} @@ -605,9 +607,9 @@ const ReviewBody = ({ {record.name || t('workflowLibrary.untitled')} - {record.description ? ( + {typeof description === 'string' && description ? ( - {record.description} + {description} ) : null} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.browser.test.tsx index 9a3dfae1db9..2c11bc90cb4 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.browser.test.tsx @@ -45,9 +45,12 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => k const ITEM = { name: 'Portrait', workflow_id: 'lib-portrait' }; const RECORD = { - category: 'user', + created_at: '2026-10-20T00:00:00Z', + is_public: false, name: 'Portrait', revision: 4, + updated_at: '2026-10-20T00:00:00Z', + user_id: 'user-a', workflow: { author: '', contact: '', @@ -184,7 +187,10 @@ describe('useOpenLibraryWorkflow replace', () => { expect(commands.replaceWorkflow).toHaveBeenCalledExactlyOnceWith( { projectId: 'project-1', workflowId: 'copy-1' }, expect.objectContaining({ nodes: [expect.objectContaining({ id: 'value' })] }), - { label: 'workflowLibrary.replacedLabel', source: { libraryWorkflowId: ITEM.workflow_id, revision: 4 } } + { + label: 'workflowLibrary.replacedLabel', + source: { libraryWorkflowId: ITEM.workflow_id, ownerUserId: 'user-a', revision: 4 }, + } ); expect(commands.addWorkflow).not.toHaveBeenCalled(); expect(workflowFitViewRequestStore.getSnapshot().request).toMatchObject({ @@ -249,6 +255,22 @@ describe('useOpenLibraryWorkflow replace', () => { ); }); + it('does not attach owner metadata to a bundled default workflow', async () => { + library.getLibraryWorkflowRecordCached.mockResolvedValue({ + ...RECORD, + is_public: true, + user_id: 'system', + workflow: { ...RECORD.workflow, meta: { category: 'default', version: '3.0.0' } }, + }); + + await runOpen(); + + expect(commands.addWorkflow.mock.calls[0]?.[1]?.source).toEqual({ + libraryWorkflowId: ITEM.workflow_id, + revision: 4, + }); + }); + it('withholds the update hint for a multiuser Library record without a verified owner', async () => { identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'current-user' } }; library.getLibraryWorkflowRecordCached.mockResolvedValue({ ...RECORD, is_public: true, user_id: undefined }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts index f7468c042da..19b1d1a4c30 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts @@ -1,6 +1,7 @@ import type { ProjectWorkflowEntry, ProjectWorkflowSource } from '@features/workflow/core/types'; import type { LibraryOpenItem } from '@features/workflow/ui/workflowUiStore'; +import { getWorkflowRecordCategory } from '@features/workflow/data/api'; import { updateLoadedWorkflowNodes } from '@features/workflow/data/templates'; import { getLibraryWorkflowRecordCached, touchLibraryWorkflowOpenedAt } from '@features/workflow/queries'; import { requestWorkflowFitView } from '@features/workflow/ui/editor/flowInstanceStore'; @@ -119,7 +120,7 @@ export const useOpenLibraryWorkflow = (onOpened: () => void): OpenLibraryWorkflo const source: ProjectWorkflowSource = { libraryWorkflowId: record.workflow_id, revision: record.revision, - ...(record.category === 'user' && record.user_id ? { ownerUserId: record.user_id } : {}), + ...(getWorkflowRecordCategory(record) === 'user' ? { ownerUserId: record.user_id } : {}), }; if (target.kind === 'replace') { From f777a4b95a720ce4a23a7678c0874f38c517419a Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Wed, 21 Oct 2026 05:52:16 -0500 Subject: [PATCH 3/5] fix(workflow): preserve concurrent visibility requests Keep pending and result feedback scoped per workflow. Fence responses by request identity and account scope. --- ...orkflowLibraryDetailPanel.browser.test.tsx | 122 ++++++++++++++++++ .../ui/library/WorkflowLibraryDetailPanel.tsx | 97 +++++++++----- 2 files changed, 187 insertions(+), 32 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx index f7bc9b14350..5999fec6c85 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx @@ -318,6 +318,11 @@ const IMAGE_TO_VIDEO = entry( ['Wan 2.2'] ); +const privateWorkflowEntry = (base: WorkflowLibraryEntry, workflowId: string): WorkflowLibraryEntry => ({ + ...base, + item: { ...base.item, is_public: false, user_id: 'user-a', workflow_id: workflowId }, +}); + const DEFAULT_CATEGORY = entry( { category: 'default', name: 'Bundled default', workflow_id: 'wf-default' }, readyEnrichment(SDXL_REQUIREMENTS) @@ -420,6 +425,13 @@ describe('WorkflowLibraryDetailPanel', () => { }; const panel = () => document.querySelector('[data-workflow-detail]'); + const toggleVisibility = async () => { + await act(async () => { + panel()?.querySelector('input[type="checkbox"]')?.focus(); + await userEvent.keyboard(' '); + await settleFrame(); + }); + }; const buttonWithText = (text: string) => [...document.querySelectorAll('button')].find((candidate) => (candidate.textContent ?? '').trim() === text); const requirementRows = () => [...document.querySelectorAll('[data-requirement-status]')]; @@ -570,6 +582,116 @@ describe('WorkflowLibraryDetailPanel', () => { expect(queries.updateLibraryWorkflow).not.toHaveBeenCalled(); }); + it('keeps overlapping visibility requests and successful values scoped per workflow', async () => { + let resolveA: (record: { is_public: boolean }) => void = NOOP; + let resolveB: (record: { is_public: boolean }) => void = NOOP; + queries.setLibraryWorkflowPublic.mockImplementation( + (workflowId) => + new Promise((resolve) => { + if (workflowId === 'wf-a') { + resolveA = resolve; + } else { + resolveB = resolve; + } + }) + ); + const privateA = privateWorkflowEntry(TEXT_TO_IMAGE, 'wf-a'); + const privateB = privateWorkflowEntry(IMAGE_TO_VIDEO, 'wf-b'); + + await renderPanel(privateA, NO_COPIES, true); + await toggleVisibility(); + + await renderPanel(privateB, NO_COPIES, true); + await toggleVisibility(); + + await act(async () => { + resolveA({ is_public: true }); + await settleFrame(); + }); + expect(panel()?.querySelector('input[type="checkbox"]')?.disabled).toBe(true); + + await act(async () => { + resolveB({ is_public: true }); + await settleFrame(); + }); + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true); + + await renderPanel(privateA, NO_COPIES, true); + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true); + await renderPanel(privateB, NO_COPIES, true); + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true); + }); + + it('keeps a successful visibility response when a later request for another workflow completes', async () => { + let resolveA: (record: { is_public: boolean }) => void = NOOP; + let resolveB: (record: { is_public: boolean }) => void = NOOP; + queries.setLibraryWorkflowPublic.mockImplementation( + (workflowId) => + new Promise((resolve) => { + if (workflowId === 'wf-a') { + resolveA = resolve; + } else { + resolveB = resolve; + } + }) + ); + const privateA = privateWorkflowEntry(TEXT_TO_IMAGE, 'wf-a'); + const privateB = privateWorkflowEntry(IMAGE_TO_VIDEO, 'wf-b'); + + await renderPanel(privateA, NO_COPIES, true); + await toggleVisibility(); + await renderPanel(privateB, NO_COPIES, true); + await toggleVisibility(); + + await act(async () => { + resolveB({ is_public: true }); + await settleFrame(); + }); + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true); + + await act(async () => { + resolveA({ is_public: true }); + await settleFrame(); + }); + await renderPanel(privateB, NO_COPIES, true); + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true); + }); + + it("preserves another workflow's visibility error when an older request succeeds", async () => { + let resolveA: (record: { is_public: boolean }) => void = NOOP; + let rejectB: (error: Error) => void = NOOP; + queries.setLibraryWorkflowPublic.mockImplementation( + (workflowId) => + new Promise((resolve, reject) => { + if (workflowId === 'wf-a') { + resolveA = resolve; + } else { + rejectB = reject; + } + }) + ); + const privateA = privateWorkflowEntry(TEXT_TO_IMAGE, 'wf-a'); + const privateB = privateWorkflowEntry(IMAGE_TO_VIDEO, 'wf-b'); + + await renderPanel(privateA, NO_COPIES, true); + await toggleVisibility(); + await renderPanel(privateB, NO_COPIES, true); + await toggleVisibility(); + + expect(queries.setLibraryWorkflowPublic.mock.calls.map(([workflowId]) => workflowId)).toEqual(['wf-a', 'wf-b']); + await act(async () => { + rejectB(new Error('Permission denied')); + await settleFrame(); + }); + expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied'); + + await act(async () => { + resolveA({ is_public: true }); + await settleFrame(); + }); + expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied'); + }); + it('keeps the private server state and shows an error when visibility cannot be changed', async () => { queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Permission denied')); const privateEntry = { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx index dd759047f61..1272a983364 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx @@ -63,6 +63,16 @@ import { const DETAIL_RAIL_WIDTH = '18rem'; const INSTALL_HOVER = { opacity: 0.85 } as const; +type VisibilityState = + | { requestId: object; previousItem: WorkflowLibraryListItem; isPublic: boolean; status: 'pending' | 'success' } + | { + requestId: object; + previousItem: WorkflowLibraryListItem; + isPublic: boolean; + message: string; + status: 'error'; + }; + export interface WorkflowLibraryDetailPanelProps { /** Where a card's right-click asked for the actions menu; null while it is closed. */ contextMenuPoint: WorkflowCardMenuAnchor | null; @@ -113,16 +123,9 @@ export const WorkflowLibraryDetailPanel = ({ // Guard duplicate creation until the copy exists, including copies landing outside the visible category. const [isDuplicatePending, setIsDuplicatePending] = useState(false); const isDuplicatePendingRef = useRef(false); - const [visibilityRequest, setVisibilityRequest] = useState< - { workflowId: string; status: 'pending' } | { workflowId: string; message: string; status: 'error' } | null - >(null); - const [visibilityOverride, setVisibilityOverride] = useState<{ - workflowId: string; - isPublic: boolean; - previousItem: WorkflowLibraryListItem; - } | null>(null); + const [visibilityStates, setVisibilityStates] = useState>(() => new Map()); const visibilityInputRef = useRef(null); - const restoreVisibilityFocus = useRef(false); + const restoreVisibilityFocus = useRef(null); const enrichment = entry?.enrichment ?? null; // Shares the grid's per-entry cache, so selecting a card the badges already @@ -402,22 +405,21 @@ export const WorkflowLibraryDetailPanel = ({ const item = entry?.item ?? null; const tags = entry?.tags ?? []; const name = item ? item.name || t('workflowLibrary.untitled') : ''; + const visibilityState = item ? visibilityStates.get(item.workflow_id) : undefined; + const selectedWorkflowId = item?.workflow_id ?? null; const itemIsPublic = - item && visibilityOverride?.workflowId === item.workflow_id && visibilityOverride.previousItem === item - ? visibilityOverride.isPublic - : item?.is_public === true; - const isVisibilityPending = - item !== null && visibilityRequest?.workflowId === item.workflow_id && visibilityRequest.status === 'pending'; - const visibilityError = - item !== null && visibilityRequest?.workflowId === item.workflow_id && visibilityRequest.status === 'error' - ? visibilityRequest.message - : null; + item && visibilityState?.previousItem === item ? visibilityState.isPublic : item?.is_public === true; + const isVisibilityPending = visibilityState?.status === 'pending'; + const visibilityError = visibilityState?.status === 'error' ? visibilityState.message : null; useLayoutEffect(() => { - if (restoreVisibilityFocus.current && !isVisibilityPending) { - restoreVisibilityFocus.current = false; + if (restoreVisibilityFocus.current !== null && restoreVisibilityFocus.current !== selectedWorkflowId) { + restoreVisibilityFocus.current = null; + } + if (selectedWorkflowId && restoreVisibilityFocus.current === selectedWorkflowId && !isVisibilityPending) { + restoreVisibilityFocus.current = null; visibilityInputRef.current?.focus(); } - }, [isVisibilityPending, itemIsPublic]); + }, [isVisibilityPending, itemIsPublic, selectedWorkflowId]); const updateVisibility = useCallback( async (isPublic: boolean) => { if (!item || !canChangeVisibility || isVisibilityPending || isPublic === itemIsPublic) { @@ -427,28 +429,59 @@ export const WorkflowLibraryDetailPanel = ({ const owner = captureAccountScope(); const workflowId = item.workflow_id; const previousItem = item; - restoreVisibilityFocus.current = document.activeElement === visibilityInputRef.current; - setVisibilityRequest({ status: 'pending', workflowId }); + const requestId = {}; + const previousVisibility = itemIsPublic; + restoreVisibilityFocus.current = document.activeElement === visibilityInputRef.current ? workflowId : null; + setVisibilityStates((current) => + new Map(current).set(workflowId, { + isPublic: previousVisibility, + previousItem, + requestId, + status: 'pending', + }) + ); try { const record = await setLibraryWorkflowPublic(workflowId, isPublic, owner.signal); assertAccountScopeCurrent(owner); - setVisibilityOverride({ isPublic: record.is_public === true, previousItem, workflowId }); + setVisibilityStates((current) => { + if (current.get(workflowId)?.requestId !== requestId) { + return current; + } + return new Map(current).set(workflowId, { + isPublic: record.is_public === true, + previousItem, + requestId, + status: 'success', + }); + }); invalidateWorkflowLibraryCache(workflowId); - setVisibilityRequest(null); } catch (error) { if (isAccountScopeCurrent(owner)) { - setVisibilityRequest({ - message: getApiErrorMessage(error, t('common.unknownError')), - status: 'error', - workflowId, + setVisibilityStates((current) => { + if (current.get(workflowId)?.requestId !== requestId) { + return current; + } + return new Map(current).set(workflowId, { + isPublic: previousVisibility, + message: getApiErrorMessage(error, t('common.unknownError')), + previousItem, + requestId, + status: 'error', + }); }); } } finally { - setVisibilityRequest((current) => - current?.workflowId === workflowId && current.status === 'pending' ? null : current - ); + setVisibilityStates((current) => { + const request = current.get(workflowId); + if (request?.requestId !== requestId || request.status !== 'pending') { + return current; + } + const next = new Map(current); + next.delete(workflowId); + return next; + }); } }, [canChangeVisibility, isVisibilityPending, item, itemIsPublic, t] From 75b8fc1de14cc398bb31980b63865b217783f94c Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Fri, 23 Oct 2026 07:18:42 -0500 Subject: [PATCH 4/5] fix(workflows): reconcile visibility and admin template updates Reconcile rejected visibility writes against server state and provide retry when readback fails. Allow ready multi-user admins to update saved non-bundled templates while keeping visibility controls owner-only. Correct the linked workflow CI fixture and update user guidance. --- .../08.Workflows/editor-reference.mdx | 2 +- .../Multi-User Mode/user-guide.mdx | 2 +- .../webv2/performance/browser-baseline.json | 220 +++++++++--------- .../frontend/webv2/public/locales/en.json | 1 + ...orkflowLibraryDetailPanel.browser.test.tsx | 150 +++++++++++- .../ui/library/WorkflowLibraryDetailPanel.tsx | 147 +++++++++--- .../WorkflowPublicationHost.browser.test.tsx | 20 +- .../ui/library/projectWorkflowEntries.test.ts | 57 +++++ .../ui/library/projectWorkflowEntries.ts | 4 +- .../shell/CenterArea.browser.test.tsx | 14 +- 10 files changed, 464 insertions(+), 153 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.test.ts diff --git a/docs/src/content/docs/Users Guide/08.Workflows/editor-reference.mdx b/docs/src/content/docs/Users Guide/08.Workflows/editor-reference.mdx index 5b5e79b7021..acd63f0b516 100644 --- a/docs/src/content/docs/Users Guide/08.Workflows/editor-reference.mdx +++ b/docs/src/content/docs/Users Guide/08.Workflows/editor-reference.mdx @@ -57,7 +57,7 @@ On **Yours**, **Sort by** orders the list by **Last updated**, **Last opened**, Opening a library workflow adds an independent copy to the project; the library's version is untouched. If the project already has a copy, opening the template asks what to do: **Open copy** switches to the existing copy, **Add new copy** makes another one, and **Replace copy** replaces the copy's graph with the library version while keeping its name. A replace can be undone in one step. -Changes to a project's workflows never change the library by themselves. **Save to library…**, in the editor's header, saves the workflow as a new library template with the name you give it, including its form and its current settings as defaults. For a workflow opened from one of your own templates, **Update library template** replaces that template instead. If the template has changed since you opened it, you can save as a new template instead, or review the current one and replace it deliberately. Replacing a template also affects workflows that use it through the **Call Saved Workflow** node. +Changes to a project's workflows never change the library by themselves. **Save to library…**, in the editor's header, saves the workflow as a new library template with the name you give it, including its form and its current settings as defaults. Owners can update their own linked saved workflow templates, and multi-user administrators can update any user's saved non-bundled template; bundled templates remain read-only. If the template has changed since you opened it, you can save as a new template instead, or review the current one and replace it deliberately. Replacing a template also affects workflows that use it through the **Call Saved Workflow** node. Add comma-separated tags, such as `portrait, SDXL, upscaling`, in the workflow's **Details**; they become filters in the library. Each workflow also keeps its own undo history while the project is open. 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 13ac55b70c9..97802a9d74e 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 @@ -343,7 +343,7 @@ Not unless you give them access. Boards start **restricted**: only you and admin ### Can I share my workflows with others? -Yes. In multi-user mode, make a saved Library workflow public from **Yours**, or choose **Public workflow** when saving a new Library copy. Public workflows are visible to signed-in users of the same InvokeAI instance and appear in **Shared**. Other users can open, duplicate, or download them; only the owner is offered rename, delete, and visibility controls in the Library. For a project copy linked to a public workflow, only its owner is offered **Update Template**. Other users can choose **Save as new** to create their own Library workflow. +Yes. In multi-user mode, make a saved Library workflow public from **Yours**, or choose **Public workflow** when saving a new Library copy. Public workflows are visible to signed-in users of the same InvokeAI instance and appear in **Shared**. Other users can open, duplicate, or download them; only the owner is offered rename, delete, and visibility controls in the Library. For a project copy linked to a public workflow, its owner or a ready administrator can use **Update Template**; other users can choose **Save as new** to create their own Library workflow. ### How long do sessions last? diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index f83b5c8d8f2..969ec089810 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,6 +1,6 @@ { - "browserExecutable": "/home/lstein/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome", - "capturedAt": "2026-10-18", + "browserExecutable": "/home/jsp/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome", + "capturedAt": "2026-10-23", "routes": [ { "activatedResourceBaseline": { @@ -14,13 +14,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 97.2000002861023, + "domContentLoadedMedianMs": 207.89999997615814, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 97.40000009536743, - "longestTaskMaxMs": 64, + "loadMedianMs": 208.09999999403954, + "longestTaskMaxMs": 149, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -29,14 +29,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 29, - "scriptRawBytes": 1868300, + "scriptRawBytes": 1882463, "scriptRequestCount": 25, - "totalRawBytes": 2120308 + "totalRawBytes": 2136193 }, - "routeReadyMedianMs": 215.30000019073486, + "routeReadyMedianMs": 444.30000001192093, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -52,13 +52,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 101, + "domContentLoadedMedianMs": 207.5, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 101.09999990463257, - "longestTaskMaxMs": 67, + "loadMedianMs": 207.69999998807907, + "longestTaskMaxMs": 136, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -67,14 +67,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 29, - "scriptRawBytes": 1868300, + "scriptRawBytes": 1882463, "scriptRequestCount": 25, - "totalRawBytes": 2120308 + "totalRawBytes": 2136193 }, - "routeReadyMedianMs": 225.39999961853027, + "routeReadyMedianMs": 464, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -90,29 +90,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 99.69999980926514, + "domContentLoadedMedianMs": 226.2000000178814, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 99.7999997138977, - "longestTaskMaxMs": 148, + "loadMedianMs": 226.30000001192093, + "longestTaskMaxMs": 376, "owner": "workbench", - "projectSwitchMedianMs": 34.90000009536743, + "projectSwitchMedianMs": 78.69999998807907, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 87, - "scriptRawBytes": 4199867, + "scriptRawBytes": 4238474, "scriptRequestCount": 82, - "totalRawBytes": 4496273 + "totalRawBytes": 4536602 }, - "routeReadyMedianMs": 360.90000009536743, + "routeReadyMedianMs": 813.1999999880791, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -128,29 +128,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 100.5, + "domContentLoadedMedianMs": 215.7000000178814, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 100.60000038146973, - "longestTaskMaxMs": 146, + "loadMedianMs": 215.90000000596046, + "longestTaskMaxMs": 351, "owner": "workbench", - "projectSwitchMedianMs": 48.40000009536743, + "projectSwitchMedianMs": 87.59999999403954, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 87, - "scriptRawBytes": 4199867, + "scriptRawBytes": 4238474, "scriptRequestCount": 82, - "totalRawBytes": 4496273 + "totalRawBytes": 4536602 }, - "routeReadyMedianMs": 367.80000019073486, + "routeReadyMedianMs": 798.2000000178814, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -159,20 +159,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305523, + "largestAssetRawBytes": 306152, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 524052, + "scriptRawBytes": 522423, "scriptRequestCount": 9, - "totalRawBytes": 524052 + "totalRawBytes": 522423 }, - "domContentLoadedMedianMs": 100.40000009536743, + "domContentLoadedMedianMs": 211.90000000596046, "id": "editor-canvas", - "layoutAckMedianMs": 2.8000001907348633, - "layoutReturnSwitchMedianMs": 51.39999961853027, - "layoutSwitchMedianMs": 70.09999990463257, - "loadMedianMs": 100.5, - "longestTaskMaxMs": 149, + "layoutAckMedianMs": 7.5999999940395355, + "layoutReturnSwitchMedianMs": 78.2999999821186, + "layoutSwitchMedianMs": 144.7000000178814, + "loadMedianMs": 212, + "longestTaskMaxMs": 343, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -181,14 +181,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 96, - "scriptRawBytes": 4718489, + "scriptRawBytes": 4760897, "scriptRequestCount": 91, - "totalRawBytes": 5014895 + "totalRawBytes": 5059025 }, - "routeReadyMedianMs": 1095.3000001907349, + "routeReadyMedianMs": 1728.5, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -197,20 +197,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305523, + "largestAssetRawBytes": 306152, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 524052, + "scriptRawBytes": 522423, "scriptRequestCount": 9, - "totalRawBytes": 524052 + "totalRawBytes": 522423 }, - "domContentLoadedMedianMs": 97.7000002861023, + "domContentLoadedMedianMs": 215.69999998807907, "id": "editor-canvas", - "layoutAckMedianMs": 3, - "layoutReturnSwitchMedianMs": 45.30000019073486, - "layoutSwitchMedianMs": 68.90000009536743, - "loadMedianMs": 97.90000009536743, - "longestTaskMaxMs": 148, + "layoutAckMedianMs": 6.5, + "layoutReturnSwitchMedianMs": 80.09999999403954, + "layoutSwitchMedianMs": 152.19999998807907, + "loadMedianMs": 215.90000000596046, + "longestTaskMaxMs": 364, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -219,14 +219,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 96, - "scriptRawBytes": 4718489, + "scriptRawBytes": 4760897, "scriptRequestCount": 91, - "totalRawBytes": 5014895 + "totalRawBytes": 5059025 }, - "routeReadyMedianMs": 1042.5, + "routeReadyMedianMs": 1768.800000011921, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -235,20 +235,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 208822, + "largestAssetRawBytes": 208701, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 451936, + "scriptRawBytes": 454172, "scriptRequestCount": 6, - "totalRawBytes": 467349 + "totalRawBytes": 469585 }, - "domContentLoadedMedianMs": 101.40000009536743, + "domContentLoadedMedianMs": 210.2000000178814, "id": "editor-workflow", - "layoutAckMedianMs": 2.9000000953674316, - "layoutReturnSwitchMedianMs": 32.700000286102295, - "layoutSwitchMedianMs": 73.09999990463257, - "loadMedianMs": 101.5, - "longestTaskMaxMs": 146, + "layoutAckMedianMs": 6.699999988079071, + "layoutReturnSwitchMedianMs": 62.599999994039536, + "layoutSwitchMedianMs": 151.19999998807907, + "loadMedianMs": 210.40000000596046, + "longestTaskMaxMs": 348, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -257,14 +257,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 94, - "scriptRawBytes": 4644510, + "scriptRawBytes": 4692646, "scriptRequestCount": 88, - "totalRawBytes": 4956329 + "totalRawBytes": 5006187 }, - "routeReadyMedianMs": 1093.0999999046326, + "routeReadyMedianMs": 1720.4000000059605, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -273,20 +273,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 208822, + "largestAssetRawBytes": 208701, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 451936, + "scriptRawBytes": 454172, "scriptRequestCount": 6, - "totalRawBytes": 467349 + "totalRawBytes": 469585 }, - "domContentLoadedMedianMs": 99.89999961853027, + "domContentLoadedMedianMs": 203.59999999403954, "id": "editor-workflow", - "layoutAckMedianMs": 2.799999713897705, - "layoutReturnSwitchMedianMs": 41.09999990463257, - "layoutSwitchMedianMs": 125.60000038146973, - "loadMedianMs": 100, - "longestTaskMaxMs": 226, + "layoutAckMedianMs": 6.5, + "layoutReturnSwitchMedianMs": 72.80000001192093, + "layoutSwitchMedianMs": 273.80000001192093, + "loadMedianMs": 203.89999997615814, + "longestTaskMaxMs": 511, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -295,14 +295,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 94, - "scriptRawBytes": 4644510, + "scriptRawBytes": 4692646, "scriptRequestCount": 88, - "totalRawBytes": 4956329 + "totalRawBytes": 5006187 }, - "routeReadyMedianMs": 1105.0999999046326, + "routeReadyMedianMs": 1895.5999999940395, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -318,13 +318,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 98.30000019073486, + "domContentLoadedMedianMs": 210.09999999403954, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 43.59999990463257, - "loadMedianMs": 98.5, - "longestTaskMaxMs": 144, + "layoutSwitchMedianMs": 90.5, + "loadMedianMs": 210.40000000596046, + "longestTaskMaxMs": 365, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -333,14 +333,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 87, - "scriptRawBytes": 4199867, + "scriptRawBytes": 4238474, "scriptRequestCount": 82, - "totalRawBytes": 4496273 + "totalRawBytes": 4536602 }, - "routeReadyMedianMs": 1324.5, + "routeReadyMedianMs": 1845.0999999940395, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -356,13 +356,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 99.80000019073486, + "domContentLoadedMedianMs": 203, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 50, - "loadMedianMs": 99.90000009536743, - "longestTaskMaxMs": 145, + "layoutSwitchMedianMs": 110.19999998807907, + "loadMedianMs": 203.2999999821186, + "longestTaskMaxMs": 329, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -371,14 +371,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 752413, - "otherRawBytes": 226185, + "largestAssetRawBytes": 752422, + "otherRawBytes": 227907, "requestCount": 87, - "scriptRawBytes": 4199867, + "scriptRawBytes": 4238474, "scriptRequestCount": 82, - "totalRawBytes": 4496273 + "totalRawBytes": 4536602 }, - "routeReadyMedianMs": 1301.5, + "routeReadyMedianMs": 2009.0999999940395, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index a92d672b5f4..0be7d821f55 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -5186,6 +5186,7 @@ "thumbnailUpdating": "Updating thumbnail…", "visibility": "Visibility", "visibilityUpdateFailed": "Failed to update workflow visibility", + "visibilityUnknown": "Visibility unknown", "title": "Workflows", "unavailableBundledBody": "\"{{name}}\" came from a bundled template, which cannot be changed. Save it as a new template instead.", "unavailableForbiddenBody": "The library template behind \"{{name}}\" belongs to someone else. Save it as a new template instead.", diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx index 5999fec6c85..7f49d3f41c1 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx @@ -10,6 +10,7 @@ import { ChakraProvider } from '@chakra-ui/react'; import { GalleryHostProvider } from '@features/gallery/picker'; import { WorkflowGraphPreviewProvider, WorkflowUiProvider } from '@features/workflow/ui/WorkflowUiContext'; import { createProjectGraph, serializeWorkflowJson } from '@features/workflow/utility'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; import { closingFrames, recordDialogExit } from '@platform/ui/dialogExit.testing'; import { system } from '@theme/system'; import { act, StrictMode, type ReactNode } from 'react'; @@ -192,6 +193,8 @@ const TRANSLATIONS: Record = { 'workflowLibrary.publicVisibilityHelp': 'Visible to users of this InvokeAI instance.', 'workflowLibrary.publicWorkflow': 'Public workflow', 'workflowLibrary.visibilityUpdateFailed': 'Failed to update workflow visibility', + 'workflowLibrary.visibilityUnknown': 'Visibility unknown', + 'workflowLibrary.retry': 'Retry', }; const interpolate = (template: string, options?: Record): string => @@ -485,6 +488,7 @@ describe('WorkflowLibraryDetailPanel', () => { onDuplicated = vi.fn((_workflowId: string) => {}); onOpen = vi.fn(); onPreview = vi.fn(); + accountLifecycle.invalidate(); models.activeInstallSources.current = new Set(); models.installedModels.current = [INSTALLED_SDXL_MAIN, INSTALLED_WAN_VAE]; @@ -499,6 +503,7 @@ describe('WorkflowLibraryDetailPanel', () => { queries.deleteLibraryWorkflow.mockResolvedValue(undefined); queries.getLibraryWorkflowCached.mockClear(); queries.getLibraryWorkflowCached.mockResolvedValue(RAW_WORKFLOW); + queries.getLibraryWorkflowRecord.mockReset().mockResolvedValue({} as Record); queries.getLibraryWorkflowRecordCached.mockClear(); queries.getLibraryWorkflowRecordCached.mockResolvedValue(RAW_RECORD); queries.invalidateWorkflowLibraryCache.mockClear(); @@ -670,6 +675,7 @@ describe('WorkflowLibraryDetailPanel', () => { } }) ); + queries.getLibraryWorkflowRecord.mockRejectedValueOnce(new Error('Read failed')); const privateA = privateWorkflowEntry(TEXT_TO_IMAGE, 'wf-a'); const privateB = privateWorkflowEntry(IMAGE_TO_VIDEO, 'wf-b'); @@ -692,8 +698,9 @@ describe('WorkflowLibraryDetailPanel', () => { expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied'); }); - it('keeps the private server state and shows an error when visibility cannot be changed', async () => { + it('shows visibility as unknown when the write and its readback both fail', async () => { queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Permission denied')); + queries.getLibraryWorkflowRecord.mockRejectedValueOnce(new Error('Read failed')); const privateEntry = { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' }, @@ -709,9 +716,145 @@ describe('WorkflowLibraryDetailPanel', () => { }); expect(control?.checked).toBe(false); - expect(control?.disabled).toBe(false); + expect(panel()?.querySelector('input[type="checkbox"]')).toBeNull(); + expect(panel()?.textContent).toContain('Visibility unknown'); expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied'); - expect(queries.invalidateWorkflowLibraryCache).not.toHaveBeenCalled(); + expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image'); + }); + + it('reconciles a rejected visibility write from the server and allows another toggle', async () => { + queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out')); + queries.getLibraryWorkflowRecord.mockResolvedValueOnce({ is_public: true } as Record); + const privateEntry = { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } }; + + await renderPanel(privateEntry, NO_COPIES, true); + await toggleVisibility(); + + const publicControl = panel()?.querySelector('input[type="checkbox"]'); + expect(publicControl?.checked).toBe(true); + expect(publicControl?.disabled).toBe(false); + expect(panel()?.textContent).toContain('Public'); + expect(queries.getLibraryWorkflowRecord).toHaveBeenCalledWith('wf-text-to-image', expect.anything()); + expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledWith('wf-text-to-image'); + + await toggleVisibility(); + expect(queries.setLibraryWorkflowPublic).toHaveBeenLastCalledWith('wf-text-to-image', false, expect.anything()); + }); + + it('keeps an unchanged authoritative value and explains a rejected visibility write', async () => { + queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Permission denied')); + queries.getLibraryWorkflowRecord.mockResolvedValueOnce({ is_public: false } as Record); + await renderPanel( + { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } }, + NO_COPIES, + true + ); + await toggleVisibility(); + + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(false); + expect(panel()?.querySelector('input[type="checkbox"]')?.disabled).toBe(false); + expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied'); + expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image'); + }); + + it('shows unknown disabled visibility and retries reconciliation when both write and readback fail', async () => { + queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out')); + queries.getLibraryWorkflowRecord.mockRejectedValueOnce(new Error('Read failed')); + await renderPanel( + { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } }, + NO_COPIES, + true + ); + await toggleVisibility(); + + expect(panel()?.textContent).toContain('Visibility unknown'); + expect(panel()?.querySelector('input[type="checkbox"]')).toBeNull(); + expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Request timed out'); + expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image'); + + queries.getLibraryWorkflowRecord.mockResolvedValueOnce({ is_public: true } as Record); + await act(async () => { + await userEvent.click(buttonWithText('Retry')!); + await settleFrame(); + }); + + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true); + expect(panel()?.querySelector('input[type="checkbox"]')?.disabled).toBe(false); + expect(panel()?.textContent).toContain('Public'); + expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledTimes(2); + }); + + it('keeps replacement entries unknown and hides the stale switch until retry succeeds', async () => { + queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out')); + queries.getLibraryWorkflowRecord.mockRejectedValueOnce(new Error('Read failed')); + const privateEntry = { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } }; + await renderPanel(privateEntry, NO_COPIES, true); + await toggleVisibility(); + + expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image'); + await renderPanel({ ...privateEntry, item: { ...privateEntry.item, is_public: true } }, NO_COPIES, true); + + expect(panel()?.querySelector('[data-workflow-visibility="unknown"]')?.textContent).toBe('Visibility unknown'); + expect(panel()?.querySelector('input[type="checkbox"]')).toBeNull(); + expect(buttonWithText('Retry')).toBeDefined(); + }); + + it('returns focus to the visibility switch after a keyboard retry succeeds', async () => { + queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out')); + queries.getLibraryWorkflowRecord + .mockRejectedValueOnce(new Error('Read failed')) + .mockResolvedValueOnce({ is_public: true } as Record); + await renderPanel( + { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } }, + NO_COPIES, + true + ); + const initialControl = panel()?.querySelector('input[type="checkbox"]'); + initialControl?.focus(); + await act(async () => { + await userEvent.keyboard(' '); + await settleFrame(); + }); + + const retry = buttonWithText('Retry'); + retry?.focus(); + await act(async () => { + await userEvent.keyboard('{Enter}'); + await settleFrame(); + }); + + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true); + expect(document.activeElement).toBe(panel()?.querySelector('input[type="checkbox"]')); + }); + + it('ignores a visibility readback that resolves after the account lifecycle rotates', async () => { + let resolveReadback: (record: { is_public: boolean }) => void = NOOP; + queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out')); + queries.getLibraryWorkflowRecord.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveReadback = resolve; + }) + ); + try { + await renderPanel( + { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } }, + NO_COPIES, + true + ); + await toggleVisibility(); + accountLifecycle.invalidate(); + + await act(async () => { + resolveReadback({ is_public: true }); + await settleFrame(); + }); + + expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(false); + expect(panel()?.querySelector('[data-workflow-visibility="private"]')?.textContent).toBe('Private'); + } finally { + accountLifecycle.invalidate(); + } }); it('returns a public workflow to Private and invalidates its library caches', async () => { @@ -1116,7 +1259,6 @@ describe('WorkflowLibraryDetailPanel', () => { }) ) ); - expect(onDeleted).toHaveBeenCalledTimes(1); expect(panel()).toBeNull(); expect(frames).not.toHaveLength(0); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx index 1272a983364..f43d1bc1ce2 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx @@ -70,7 +70,7 @@ type VisibilityState = previousItem: WorkflowLibraryListItem; isPublic: boolean; message: string; - status: 'error'; + status: 'error' | 'unknown' | 'reconciling'; }; export interface WorkflowLibraryDetailPanelProps { @@ -407,22 +407,78 @@ export const WorkflowLibraryDetailPanel = ({ const name = item ? item.name || t('workflowLibrary.untitled') : ''; const visibilityState = item ? visibilityStates.get(item.workflow_id) : undefined; const selectedWorkflowId = item?.workflow_id ?? null; - const itemIsPublic = - item && visibilityState?.previousItem === item ? visibilityState.isPublic : item?.is_public === true; + const visibilityUnknown = visibilityState?.status === 'unknown' || visibilityState?.status === 'reconciling'; + const itemIsPublic = visibilityUnknown + ? undefined + : item && visibilityState?.previousItem === item + ? visibilityState.isPublic + : item?.is_public === true; const isVisibilityPending = visibilityState?.status === 'pending'; - const visibilityError = visibilityState?.status === 'error' ? visibilityState.message : null; + const visibilityError = visibilityState?.status === 'error' || visibilityUnknown ? visibilityState.message : null; useLayoutEffect(() => { if (restoreVisibilityFocus.current !== null && restoreVisibilityFocus.current !== selectedWorkflowId) { restoreVisibilityFocus.current = null; } - if (selectedWorkflowId && restoreVisibilityFocus.current === selectedWorkflowId && !isVisibilityPending) { + if ( + selectedWorkflowId && + restoreVisibilityFocus.current === selectedWorkflowId && + !isVisibilityPending && + !visibilityUnknown + ) { restoreVisibilityFocus.current = null; visibilityInputRef.current?.focus(); } - }, [isVisibilityPending, itemIsPublic, selectedWorkflowId]); + }, [isVisibilityPending, itemIsPublic, selectedWorkflowId, visibilityUnknown]); + const reconcileVisibility = useCallback( + async ( + workflowId: string, + previousItem: WorkflowLibraryListItem, + requestId: object, + owner: ReturnType, + requestedVisibility?: boolean, + failureMessage?: string + ) => { + if (!isAccountScopeCurrent(owner)) { + return; + } + invalidateWorkflowLibraryCache(workflowId); + try { + const record = await getLibraryWorkflowRecord(workflowId, owner.signal); + assertAccountScopeCurrent(owner); + const isPublic = record.is_public === true; + setVisibilityStates((current) => { + if (current.get(workflowId)?.requestId !== requestId) { + return current; + } + const state = { isPublic, previousItem, requestId }; + return new Map(current).set( + workflowId, + requestedVisibility !== undefined && isPublic !== requestedVisibility + ? { ...state, message: failureMessage ?? t('common.unknownError'), status: 'error' } + : { ...state, status: 'success' } + ); + }); + } catch (error) { + if (isAccountScopeCurrent(owner)) { + setVisibilityStates((current) => { + const state = current.get(workflowId); + if (state?.requestId !== requestId) { + return current; + } + return new Map(current).set(workflowId, { + ...state, + message: failureMessage ?? getApiErrorMessage(error, t('common.unknownError')), + status: 'unknown', + }); + }); + } + } + }, + [t] + ); const updateVisibility = useCallback( async (isPublic: boolean) => { - if (!item || !canChangeVisibility || isVisibilityPending || isPublic === itemIsPublic) { + if (!item || !canChangeVisibility || isVisibilityPending || visibilityUnknown || isPublic === itemIsPublic) { return; } @@ -430,7 +486,7 @@ export const WorkflowLibraryDetailPanel = ({ const workflowId = item.workflow_id; const previousItem = item; const requestId = {}; - const previousVisibility = itemIsPublic; + const previousVisibility = itemIsPublic === true; restoreVisibilityFocus.current = document.activeElement === visibilityInputRef.current ? workflowId : null; setVisibilityStates((current) => new Map(current).set(workflowId, { @@ -458,20 +514,17 @@ export const WorkflowLibraryDetailPanel = ({ }); invalidateWorkflowLibraryCache(workflowId); } catch (error) { - if (isAccountScopeCurrent(owner)) { - setVisibilityStates((current) => { - if (current.get(workflowId)?.requestId !== requestId) { - return current; - } - return new Map(current).set(workflowId, { - isPublic: previousVisibility, - message: getApiErrorMessage(error, t('common.unknownError')), - previousItem, - requestId, - status: 'error', - }); - }); + if (!isAccountScopeCurrent(owner)) { + return; } + await reconcileVisibility( + workflowId, + previousItem, + requestId, + owner, + isPublic, + getApiErrorMessage(error, t('common.unknownError')) + ); } finally { setVisibilityStates((current) => { const request = current.get(workflowId); @@ -484,8 +537,27 @@ export const WorkflowLibraryDetailPanel = ({ }); } }, - [canChangeVisibility, isVisibilityPending, item, itemIsPublic, t] + [canChangeVisibility, isVisibilityPending, item, itemIsPublic, reconcileVisibility, t, visibilityUnknown] ); + const retryVisibilityRead = useCallback(async () => { + if (!item || !visibilityUnknown || !visibilityState || visibilityState.status === 'reconciling') { + return; + } + const owner = captureAccountScope(); + const workflowId = item.workflow_id; + const requestId = {}; + const previousItem = item; + setVisibilityStates((current) => + new Map(current).set(workflowId, { + ...visibilityState, + previousItem, + requestId, + status: 'reconciling', + }) + ); + await reconcileVisibility(workflowId, previousItem, requestId, owner); + }, [item, reconcileVisibility, visibilityState, visibilityUnknown]); + const handleRetryVisibilityRead = useCallback(() => void retryVisibilityRead(), [retryVisibilityRead]); const handleVisibilityClick = useCallback( (event: MouseEvent) => { event.preventDefault(); @@ -586,13 +658,27 @@ export const WorkflowLibraryDetailPanel = ({ {t('workflowLibrary.visibility')} - - {t(itemIsPublic ? 'workflowLibrary.public' : 'workflowLibrary.private')} + + {t( + visibilityUnknown + ? 'workflowLibrary.visibilityUnknown' + : itemIsPublic + ? 'workflowLibrary.public' + : 'workflowLibrary.private' + )} - {canChangeVisibility ? ( + {canChangeVisibility && !visibilityUnknown ? ( - + ) : null} + {visibilityUnknown ? ( + + ) : null} ) : null} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx index 6f09ec35612..4d7b0097068 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx @@ -664,28 +664,32 @@ describe('WorkflowPublicationHost', () => { expect(api.updateLibraryWorkflow).not.toHaveBeenCalled(); }); - it('lets an admin open another account’s public workflow but only save a new template from it', async () => { + it.each([false, true])('lets an admin update another account’s %s workflow', async (isPublic) => { identity.session = { multiuserEnabled: true, phase: 'ready', user: { is_admin: true, user_id: 'admin-user' }, }; - const publicWorkflowFromAnotherAccount: ProjectWorkflowEntry = { + const workflowFromAnotherAccount: ProjectWorkflowEntry = { ...LINKED, source: { ...LINKED.source!, ownerUserId: 'owner-user' }, }; - api.getLibraryWorkflowRecord.mockResolvedValue(record({ is_public: true, revision: 3 })); + api.getLibraryWorkflowRecord.mockResolvedValue(record({ is_public: isPublic, user_id: 'owner-user', revision: 3 })); + api.updateLibraryWorkflow.mockResolvedValue(record({ is_public: isPublic, user_id: 'owner-user', revision: 4 })); - await renderHost([publicWorkflowFromAnotherAccount]); + await renderHost([workflowFromAnotherAccount]); await request('update-source'); - expect(dialogTitled('Template cannot be updated')?.textContent).toContain('belongs to someone else'); - expect(choice('save-as-new')).not.toBeNull(); + expect(dialogTitled('Update library template')).not.toBeNull(); expect(api.updateLibraryWorkflow).not.toHaveBeenCalled(); - await click(choice('save-as-new')); + await confirmUpdate(); - expect(saveDialog()).not.toBeNull(); + expect(api.updateLibraryWorkflow).toHaveBeenCalledWith( + LIBRARY_ID, + expect.anything(), + expect.objectContaining({ expectedRevision: 3 }) + ); }); it('offers a retry after a lost answer and resends under the same reserved id', async () => { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.test.ts b/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.test.ts new file mode 100644 index 00000000000..ae776287371 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.test.ts @@ -0,0 +1,57 @@ +import type { AuthSession } from '@features/identity'; +import type { ProjectWorkflowSource } from '@features/workflow/core/types'; + +import { describe, expect, it } from 'vitest'; + +import { isUpdatableSource } from './projectWorkflowEntries'; + +const session = (overrides: Partial = {}): AuthSession => ({ + accountEpoch: 1, + multiuserEnabled: true, + phase: 'ready', + sessionExpired: false, + setupRequired: false, + strictPasswordChecking: false, + user: { + created_at: '', + display_name: null, + email: '', + is_active: true, + is_admin: false, + last_login_at: null, + updated_at: '', + user_id: 'user-a', + }, + ...overrides, +}); + +const admin = session({ + user: { + created_at: '', + display_name: null, + email: '', + is_active: true, + is_admin: true, + last_login_at: null, + updated_at: '', + user_id: 'admin', + }, +}); + +const source = (overrides: Partial = {}): ProjectWorkflowSource => ({ + libraryWorkflowId: 'library-1', + ownerUserId: 'user-a', + revision: 1, + ...overrides, +}); + +describe('isUpdatableSource', () => { + it('allows admins to update foreign saved records while keeping bundled, ordinary foreign, and unresolved sources blocked', () => { + const foreignSource = source({ ownerUserId: 'user-b' }); + + expect(isUpdatableSource(foreignSource, admin)).toBe(true); + expect(isUpdatableSource(foreignSource, session())).toBe(false); + expect(isUpdatableSource(source({ libraryWorkflowId: 'default_template' }), admin)).toBe(false); + expect(isUpdatableSource(foreignSource, session({ ...admin, phase: 'unknown' }))).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts b/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts index d052ca09629..1b4340493a0 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts @@ -30,7 +30,9 @@ export const isUpdatableSource = (source: ProjectWorkflowSource | undefined, ses source !== undefined && session.phase === 'ready' && !isBundledLibraryWorkflowId(source.libraryWorkflowId) && - (!session.multiuserEnabled || (Boolean(session.user?.user_id) && source.ownerUserId === session.user?.user_id)); + (!session.multiuserEnabled || + session.user?.is_admin === true || + (Boolean(session.user?.user_id) && source.ownerUserId === session.user?.user_id)); export const toProjectWorkflowLibraryEntry = ( projectWorkflow: ProjectWorkflowEntry, diff --git a/invokeai/frontend/webv2/src/workbench/shell/CenterArea.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/shell/CenterArea.browser.test.tsx index 63fcdc72eb0..bc40c31597b 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/CenterArea.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/CenterArea.browser.test.tsx @@ -72,6 +72,15 @@ const centerAreaMocks = vi.hoisted(() => { }; }); +const identity = vi.hoisted(() => ({ + session: { multiuserEnabled: true, phase: 'ready', user: { is_admin: true, user_id: 'admin-user' } }, +})); + +vi.mock('@features/identity', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuthSession: () => identity.session, +})); + vi.mock('@features/models', async (importOriginal) => ({ ...(await importOriginal()), useModelLoads: () => [], @@ -305,12 +314,13 @@ describe('CenterArea with the workflow editor chrome at a narrow width', () => { ]); const implementation = await loadWorkflowWidgetImplementation(); const document = { id: 'workflow-1', name: 'A workflow whose name is far too long for a narrow center view' }; + const linkedSource = source ? { ...source, ownerUserId: 'another-user' } : undefined; const snapshot = { - activeWorkflow: { document, source }, + activeWorkflow: { document, source: linkedSource }, activeWorkflowId: document.id, id: 'project-1', projectGraph: document, - workflows: [{ document, source }], + workflows: [{ document, source: linkedSource }], }; const port = (value: T) => ({ getSnapshot: () => value, subscribe: () => () => {} }); const adapter = { From d01887e9049a5b00fa024ccafa05fcf329eccbc5 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Fri, 23 Oct 2026 11:48:38 -0500 Subject: [PATCH 5/5] fix(workflows): preserve creation revision after visibility updates Keep the revision returned by creation when visibility succeeds or is retried. --- .../workflow/data/publication.test.ts | 35 +++++++++++++++++++ .../src/features/workflow/data/publication.ts | 24 +++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts index 56ab0364bd0..b99fd45aeb7 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts @@ -90,6 +90,22 @@ describe('workflow publication controller', () => { expect(deps.setPublic).not.toHaveBeenCalled(); }); + it('keeps the creation revision when the visibility response is newer', async () => { + deps.createRecord.mockResolvedValue( + record({ name: 'Named', revision: 1, user_id: 'user-a', workflow_id: 'reserved-1' }) + ); + deps.setPublic.mockResolvedValue( + record({ name: 'Named', is_public: true, revision: 2, workflow_id: 'reserved-1' }) + ); + + await expect(controller.publish(request({ kind: 'create', name: 'Named' }, true))).resolves.toMatchObject({ + kind: 'created', + libraryWorkflowId: 'reserved-1', + revision: 1, + status: 'published', + }); + }); + it('invalidates the private record after partial visibility and retries under one reserved id', async () => { deps.createRecord .mockRejectedValueOnce(new TypeError('create answer lost')) @@ -131,6 +147,25 @@ describe('workflow publication controller', () => { expect(deps.invalidateCache).toHaveBeenCalledWith('reserved-1'); }); + it('keeps the creation revision when a visibility retry response is newer', async () => { + deps.createRecord.mockResolvedValue(record({ revision: 1, workflow_id: 'reserved-1' })); + deps.setPublic + .mockRejectedValueOnce(new TypeError('visibility answer lost')) + .mockResolvedValueOnce(record({ is_public: true, revision: 2, workflow_id: 'reserved-1' })); + + const result = await controller.publish(request({ kind: 'create', name: 'Named' }, true)); + if (result.status !== 'partial') { + throw new Error('expected the visibility failure'); + } + + await expect(result.retry()).resolves.toMatchObject({ + kind: 'created', + libraryWorkflowId: 'reserved-1', + revision: 1, + status: 'published', + }); + }); + it('refuses to overlap a publication of the same workflow and reports when it is busy', async () => { const first = deferred(); deps.createRecord.mockReturnValueOnce(first.promise); diff --git a/invokeai/frontend/webv2/src/features/workflow/data/publication.ts b/invokeai/frontend/webv2/src/features/workflow/data/publication.ts index f40e2f88f93..38708ec3d16 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/publication.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/publication.ts @@ -127,13 +127,14 @@ export const isWorkflowContentEquivalent = ( const toPublished = ( kind: 'created' | 'updated', - record: WorkflowRecordDTO + record: WorkflowRecordDTO, + revision = record.revision ): Extract => ({ kind, libraryWorkflowId: record.workflow_id, name: record.name, ...(getWorkflowRecordCategory(record) === 'user' ? { ownerUserId: record.user_id } : {}), - revision: record.revision, + revision, status: 'published', }); @@ -225,11 +226,18 @@ export const createWorkflowPublicationController = ( }; const create = (request: WorkflowPublicationRequest, name: string, reservedId: string) => { - const retryVisibility = (libraryWorkflowId: string): Promise => { + const retryVisibility = ( + libraryWorkflowId: string, + creationRevision: number + ): Promise => { const attempt = (): Promise => settle(request, async () => { try { - return toPublished('created', await deps.setPublic(libraryWorkflowId, true, request.owner.signal)); + return toPublished( + 'created', + await deps.setPublic(libraryWorkflowId, true, request.owner.signal), + creationRevision + ); } catch (error) { if (!isAccountScopeCurrent(request.owner)) { return { status: 'cancelled' }; @@ -259,7 +267,11 @@ export const createWorkflowPublicationController = ( if (request.isPublic === true) { try { - return toPublished('created', await deps.setPublic(record.workflow_id, true, request.owner.signal)); + return toPublished( + 'created', + await deps.setPublic(record.workflow_id, true, request.owner.signal), + record.revision + ); } catch (error) { if (!isAccountScopeCurrent(request.owner)) { return { status: 'cancelled' }; @@ -268,7 +280,7 @@ export const createWorkflowPublicationController = ( return { libraryWorkflowId: record.workflow_id, message: getApiErrorMessage(error, 'The library did not answer.'), - retry: () => retryVisibility(record.workflow_id), + retry: () => retryVisibility(record.workflow_id, record.revision), status: 'partial', }; }