Skip to content

feat: Per-user workflow libraries in multiuser mode#114

Open
Copilot wants to merge 8 commits intolstein/feature/workflow-isolation-in-multiuser-modefrom
copilot/add-per-user-workflow-libraries
Open

feat: Per-user workflow libraries in multiuser mode#114
Copilot wants to merge 8 commits intolstein/feature/workflow-isolation-in-multiuser-modefrom
copilot/add-per-user-workflow-libraries

Conversation

Copy link

Copilot AI commented Mar 3, 2026

Summary

In multiuser mode, all users shared a single workflow library. This PR isolates workflows per-user, adds a "Shared Workflows" section for publicly shared workflows, and provides controls to share/unshare workflows.

Backend

  • Migration 28: Adds user_id (DEFAULT 'system') and is_public (DEFAULT FALSE) columns + indexes to workflow_library. Uses executescript() for the DDL statements so that transaction management is handled explicitly (executescript always issues COMMIT first), which avoids edge-cases in Python's sqlite3 implicit transaction handling for DDL on tables that contain VIRTUAL generated columns. A post-check raises a clear RuntimeError if the columns were not actually added, preventing silent failures. No cross-module imports in the migration callback.
  • WORKFLOW_LIBRARY_DEFAULT_USER_ID constant added to workflow_records_common.py to avoid magic strings across service and base layers
  • workflow_records_*: All query methods (create, get_many, counts_by_*, get_all_tags) accept user_id and is_public filters; new update_is_public() method — which also automatically adds the "shared" tag when is_public=true and removes it when is_public=false
  • workflows.py router:
    • All endpoints now use CurrentUserOrDefault
    • list_workflows / counts_by_* / get_all_tags automatically scope user category results to the current user in multiuser mode (bypassed when is_public=true is explicitly requested)
    • Ownership enforced on GET (non-owner blocked unless public/default/admin), PATCH, DELETE, and thumbnail endpoints
    • New PATCH /api/v1/workflows/i/{workflow_id}/is_public endpoint

Frontend

  • openapi.json updated; schema.ts regenerated via make frontend-typegen
  • WorkflowRecordOrderBy gains is_public; WorkflowLibraryView gains 'shared'
  • Shared Workflows nav section added to WorkflowLibrarySideNav; WorkflowList routes 'shared' view to is_public=true query
  • WorkflowGeneralTab (Details panel): new ShareWorkflowCheckbox component below the Tags field — visible to the workflow owner and admins in multiuser mode; toggles is_public via the API
  • WorkflowListItem: owners see a ShareWorkflowToggle switch; public workflows display a "Shared" badge; EditWorkflow and DeleteWorkflow buttons are now gated behind isOwner || is_admin so non-owners cannot edit or delete others' workflows
  • SaveWorkflowAsDialog: "Share workflow" checkbox — marks new workflow public immediately after creation
  • WorkflowSortControl + sort options updated to include is_public
  • i18n: sharedWorkflows, shareWorkflow added to en.json

Single-user mode behavior is completely unchanged — no user filtering is applied when multiuser is false.

Related Issues / Discussions

QA Instructions

Multiuser mode (multiuser: true in config):

  1. Log in as User A, create a workflow → should appear under "Your Workflows" and be invisible to User B
  2. As User A, open the workflow Details panel and check "Share Workflow" → the workflow should appear in "Shared Workflows" for User B (read-only); verify the "shared" tag is auto-added to the workflow
  3. As User A, toggle the "Shared" switch on the workflow tile in the library — same sharing behaviour
  4. As User A, use "Save Workflow As" with "Share workflow" checked → new workflow immediately visible in "Shared Workflows" for others
  5. As User B, view User A's workflow tile → Edit and Delete buttons should not appear; direct API calls to edit/delete should return 403
  6. As Admin, view any user's workflow tile → Edit and Delete buttons should appear and succeed
  7. Sort by "Shared" in the Workflow Library

Single-user mode: verify all existing workflow operations behave identically to before.

Merge Plan

Migration 28 is additive (new nullable/defaulted columns); safe to deploy without data loss. Existing workflows are assigned user_id = 'system' and is_public = false.

The workflow_library table in real databases has accumulated schema changes from earlier migrations (VIRTUAL generated columns added via ALTER TABLE). Migration 28 uses executescript() rather than cursor.execute() for its DDL to ensure deterministic commit behaviour on such tables regardless of Python version or SQLite version.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)
Original prompt

This section details on the original issue you should resolve

<issue_title>[enhancement]: Per-user workflow libraries for users in multiuser mode</issue_title>
<issue_description>### Is there an existing issue for this?

  • I have searched the existing issues

Contact Details

No response

What should this feature add?

  1. Currently in multiuser mode, all users share a common workflow library. I would like to isolate the libraries so that, by default, each user can only list, load and edit the workflows that they have saved.

  2. As an exception to this rule, I would like workflows created by an Administrator to be visible to all users. They should appear in a new section of the Workflow Library panel labeld "Shared Workflows" and be loadable by all users, but can only be edited or deleted by the Administrator.

  3. In addition, when users save a workflow, they have the option to designate it a shared workflow. They do this in one of the following ways:

    • When they choose "Save Workflow" or "Save Workflow As", there is a "Shared workflow" checkbox in the dialogue that asks for the name of the workflow. The user can check this, in which case a logical read-only copy of the workflow will appear in the "Shared Workflows" section.
    • When the "Your Workflows" section of the Workflow Library browser is active, there is a "Shared workflow" checkbox at the right edge of the workflow tile. The user can check this, in which case a logical read-only copy of the workflow will appear in the "Shared Workflows" section.
    • Unchecking the box in the read/write copy in "Your Workflows" will unshare the workflow and it will disappear from the "Shared Workflows" section.
  4. Modify the "Order By" menu in Workflow Library to order by shared status.

Alternatives

No response

Additional Content

No response</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 2 commits March 3, 2026 02:15
…r ownership checks, is_public endpoint, schema regeneration, frontend UI

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
…ix error handling in SaveWorkflowAsDialog, rename test helper function

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
Copilot AI changed the title [WIP] Add per-user workflow libraries for multiuser mode feat: Per-user workflow libraries in multiuser mode Mar 3, 2026
@lstein lstein marked this pull request as ready for review March 3, 2026 02:39
@lstein lstein self-requested a review as a code owner March 3, 2026 02:39
Copy link
Owner

@lstein lstein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration isn't working properly. On startup, I am seeing the following:

[2026-03-02 21:39:40,270]::[InvokeAI]::INFO --> Using torch device: NVIDIA Graphics Device
[2026-03-02 21:39:40,272]::[InvokeAI]::INFO --> cuDNN version: 90701
[2026-03-02 21:39:40,548]::[InvokeAI]::INFO --> Patchmatch initialized
[2026-03-02 21:39:41,923]::[InvokeAI]::INFO --> InvokeAI version 6.11.1.post1
[2026-03-02 21:39:41,924]::[InvokeAI]::INFO --> Root directory = /home/lstein/invokeai-lstein
[2026-03-02 21:39:41,924]::[InvokeAI]::INFO --> Initializing database at /home/lstein/invokeai-lstein/databases/invokeai.db
[2026-03-02 21:39:41,926]::[InvokeAI]::INFO --> Database update needed
[2026-03-02 21:39:41,926]::[InvokeAI]::INFO --> Backing up database to /home/lstein/invokeai-lstein/databases/invokeai_backup_20260302-213941.db
[2026-03-02 21:39:41,932]::[InvokeAI]::INFO --> Database updated successfully
[2026-03-02 21:39:41,932]::[InvokeAI]::INFO --> JWT secret loaded from database
[2026-03-02 21:39:42,018]::[ModelManagerService]::INFO --> [MODEL CACHE] Calculated model RAM cache size: 12786.88 MB. Heuristics applied: [1, 2].
[2026-03-02 21:39:42,020]::[ModelInstallService]::INFO --> Restoring incomplete installs
[2026-03-02 21:39:42,021]::[ModelInstallService]::INFO --> Finished restoring incomplete installs
[a bunch of lines deleted]
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/api_app.py", line 48, in lifespan
    ApiDependencies.initialize(config=app_config, event_handler_id=event_handler_id, loop=loop, logger=logger)
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/api/dependencies.py", line 202, in initialize
    ApiDependencies.invoker = Invoker(services)
                              ^^^^^^^^^^^^^^^^^
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/invoker.py", line 14, in __init__
    self._start()
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/invoker.py", line 31, in _start
    self.__start_service(getattr(self.services, service))
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/invoker.py", line 20, in __start_service
    start_op(self)
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/workflow_records/workflow_records_sqlite.py", line 33, in start
    self._sync_default_workflows()
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/workflow_records/workflow_records_sqlite.py", line 472, in _sync_default_workflows
    workflow_from_db = self.get(workflow_from_file.id).workflow
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/workflow_records/workflow_records_sqlite.py", line 38, in get
    cursor.execute(
sqlite3.OperationalError: no such column: user_id

[2026-03-02 21:39:42,023]::[uvicorn.error]::ERROR --> Application startup failed. Exiting.

lstein and others added 2 commits March 2, 2026 21:56
…on user_id to match migration 27 pattern

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
Copy link
Author

Copilot AI commented Mar 3, 2026

The migration isn't working properly. On startup, I am seeing the following:

[2026-03-02 21:39:40,270]::[InvokeAI]::INFO --> Using torch device: NVIDIA Graphics Device...

Fixed in commit 196655d. Two changes were made to migration 28:

  1. Removed the cross-module import of WORKFLOW_LIBRARY_DEFAULT_USER_ID — the import of workflow_records_common during migration execution could fail in some environments (e.g. if the installed package and development code are mixed), so the literal 'system' is used directly instead.

  2. Removed NOT NULL from the user_id column — changed user_id TEXT NOT NULL DEFAULT 'system' to user_id TEXT DEFAULT 'system'. This matches the exact pattern used by migration 27 for every other table (boards, images, session_queue, style_presets), and avoids any potential edge-case behaviour with NOT NULL constraints on ALTER TABLE ADD COLUMN across different SQLite versions.

Copy link
Owner

@lstein lstein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something is still wrong with the migration. The schema is not being updated with the user_id column. Here is the full stack trace:

[2026-03-02 22:30:03,509]::[InvokeAI]::INFO --> Using torch device: NVIDIA Graphics Device
[2026-03-02 22:30:03,511]::[InvokeAI]::INFO --> cuDNN version: 90701
[2026-03-02 22:30:03,787]::[InvokeAI]::INFO --> Patchmatch initialized                                                                                  
[2026-03-02 22:30:05,166]::[InvokeAI]::INFO --> InvokeAI version 6.11.1.post1
[2026-03-02 22:30:05,166]::[InvokeAI]::INFO --> Root directory = /home/lstein/invokeai-lstein
[2026-03-02 22:30:05,166]::[InvokeAI]::INFO --> Initializing database at /home/lstein/invokeai-lstein/databases/invokeai.db        
[2026-03-02 22:30:05,167]::[InvokeAI]::INFO --> Database update needed
[2026-03-02 22:30:05,167]::[InvokeAI]::INFO --> Backing up database to /home/lstein/invokeai-lstein/databases/invokeai_backup_20260302-223005.db
[2026-03-02 22:30:05,173]::[InvokeAI]::INFO --> Database updated successfully                                                      
[2026-03-02 22:30:05,173]::[InvokeAI]::INFO --> JWT secret loaded from database
[2026-03-02 22:30:05,260]::[ModelManagerService]::INFO --> [MODEL CACHE] Calculated model RAM cache size: 12786.88 MB. Heuristics applied: [1, 2].
[2026-03-02 22:30:05,263]::[ModelInstallService]::INFO --> Restoring incomplete installs                                           
[2026-03-02 22:30:05,263]::[ModelInstallService]::INFO --> Finished restoring incomplete installs
[2026-03-02 22:30:05,265]::[uvicorn.error]::ERROR --> Traceback (most recent call last):
  File "/home/lstein/invokeai-lstein/.venv/lib/python3.12/site-packages/starlette/routing.py", line 694, in lifespan               
    async with self.lifespan_context(app) as maybe_state:                                                                                               
               ^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                               
  File "/home/lstein/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/contextlib.py", line 210, in __aenter__
    return await anext(self.gen)                                            
           ^^^^^^^^^^^^^^^^^^^^^                                                                                                                        
  File "/home/lstein/invokeai-lstein/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 209, in merged_lifespan          
    async with original_context(app) as maybe_original_state:                                                                                           
               ^^^^^^^^^^^^^^^^^^^^^                                        
  File "/home/lstein/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/contextlib.py", line 210, in __aenter__
    return await anext(self.gen)                                            
           ^^^^^^^^^^^^^^^^^^^^^                                                                                                                        
  File "/home/lstein/invokeai-lstein/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 209, in merged_lifespan          
    async with original_context(app) as maybe_original_state:                                                                                           
               ^^^^^^^^^^^^^^^^^^^^^                                        
  File "/home/lstein/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/contextlib.py", line 210, in __aenter__
    return await anext(self.gen)                                                                                                                        
           ^^^^^^^^^^^^^^^^^^^^^    
  File "/home/lstein/invokeai-lstein/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 209, in merged_lifespan          
    async with original_context(app) as maybe_original_state:
               ^^^^^^^^^^^^^^^^^^^^^                                                                                                                    
  File "/home/lstein/invokeai-lstein/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 209, in merged_lifespan
    async with original_context(app) as maybe_original_state:
               ^^^^^^^^^^^^^^^^^^^^^
  File "/home/lstein/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/contextlib.py", line 210, in __aenter__
    return await anext(self.gen)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/home/lstein/invokeai-lstein/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 209, in merged_lifespan
    async with original_context(app) as maybe_original_state:
               ^^^^^^^^^^^^^^^^^^^^^
  File "/home/lstein/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/contextlib.py", line 210, in __aenter__
    return await anext(self.gen)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/api_app.py", line 48, in lifespan
    ApiDependencies.initialize(config=app_config, event_handler_id=event_handler_id, loop=loop, logger=logger)
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/api/dependencies.py", line 202, in initialize
    ApiDependencies.invoker = Invoker(services)
                              ^^^^^^^^^^^^^^^^^
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/invoker.py", line 14, in __init__
    self._start()
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/invoker.py", line 31, in _start
    self.__start_service(getattr(self.services, service))
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/invoker.py", line 20, in __start_service
    start_op(self)
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/workflow_records/workflow_records_sqlite.py", line 33, in start
    self._sync_default_workflows()
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/workflow_records/workflow_records_sqlite.py", line 472, in _sync_default_workflows
    workflow_from_db = self.get(workflow_from_file.id).workflow
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lstein/Projects/InvokeAI-lstein/invokeai/app/services/workflow_records/workflow_records_sqlite.py", line 38, in get
    cursor.execute(
sqlite3.OperationalError: no such column: user_id

[2026-03-02 22:30:05,265]::[uvicorn.error]::ERROR --> Application startup failed. Exiting.

Copy link
Author

Copilot AI commented Mar 3, 2026

Something is still wrong with the migration. The schema is not being updated with the user_id column. Here is the full stack trace:

[2026-03-02 22:30:03,509]::[InvokeAI]::INFO --> Using torch devi...

Fixed in commit 6eb87a1. The .schema workflow_library output you provided was the key — it revealed that tags (a VIRTUAL generated column) and opened_at were both added to the table via previous ALTER TABLE operations, making workflow_library a table that had accumulated DDL changes over multiple migrations.

The root cause: Python's sqlite3 module manages DDL transactions implicitly, and on tables with accumulated schema changes (VIRTUAL generated columns added via ALTER TABLE), the interaction between Python's implicit transaction tracking and SQLite's DDL auto-commit behaviour could leave the ALTER TABLE silently uncommitted.

The fix switches the DDL to use cursor.connection.executescript(), which always issues an explicit COMMIT before executing, making the transaction semantics deterministic regardless of Python version or accumulated table history. A post-check was also added that raises a clear RuntimeError if the columns still aren't present after the ALTER TABLE, so any future failure surfaces with a useful message instead of the obscure no such column: user_id crash.

@lstein lstein force-pushed the copilot/add-per-user-workflow-libraries branch from 6eb87a1 to 89ef06d Compare March 3, 2026 04:21
Copy link
Owner

@lstein lstein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User isolation is working well. Now please implement workflow sharing. In the workflow "Details" panel, please add a "Shared workflow" checkbox. When this checkbox is selected, the workflow will be available in a read-only fashion to all users under the "Browse Workflows" section of the Workflow Library panel. Also auto-add the tag "shared" to these workflows so that they can be easily filtered on.

Only the owner of a workflow can edit or delete a workflow. The exception is the Administrator, who can edit or delete any user's workflow.

@lstein
Copy link
Owner

lstein commented Mar 3, 2026

@copilot
In addition, the unit test for workflow isolation is failing:

======================================================================== short test summary info ========================================================================
FAILED tests/app/routers/test_workflows_multiuser.py::test_workflows_are_isolated_between_users - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_user_cannot_delete_another_users_workflow - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_user_cannot_update_another_users_workflow - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_owner_can_delete_own_workflow - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_admin_can_delete_any_workflow - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_update_is_public_owner_succeeds - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_update_is_public_other_user_forbidden - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_public_workflow_visible_to_other_users - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_private_workflow_not_visible_to_other_users - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_public_workflow_still_in_owners_list - AssertionError: {"detail":[{"type":"list_type","loc":["body","workflow","nodes"],"msg":"Input should be a valid list","input":{}}]}
FAILED tests/app/routers/test_workflows_multiuser.py::test_workflow_has_user_id_and_is_public_fields - assert 422 == 200
=============================================================== 11 failed, 2 passed, 3 warnings in 9.22s ================================================================

…dit/delete, fix tests

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
Copy link
Author

Copilot AI commented Mar 3, 2026

User isolation is working well. Now please implement workflow sharing. In the workflow "Details" panel, please add a "Shared workflow" checkbox. When this checkbox is selected, the workflow will be av...

Done in commit eaa6af5. Changes made:

  1. "Shared workflow" checkbox in the Details panel — added to WorkflowGeneralTab below the Tags field. It's only visible to the workflow owner (or admin) in multiuser mode. Toggling it calls the existing PATCH /is_public endpoint.

  2. Auto-taggingupdate_is_public in the backend now automatically adds the "shared" tag when is_public=true and removes it when is_public=false, so shared workflows can be filtered by tag.

  3. Ownership-gated edit/deleteEditWorkflow and DeleteWorkflow buttons in the workflow list tile are now only rendered when isOwner || is_admin.

Here's how the Details panel looks with the new checkbox:

Multiuser — owner view Single-user / non-owner
Shows "Share Workflow" checkbox with current state Checkbox hidden

@copilot
In addition, the unit test for workflow isolation is failing:

...

Fixed in commit eaa6af5. Two issues were causing all 11 failures:

  1. WORKFLOW_BODY had "nodes": {} (an empty object) — the API requires a list. Fixed to "nodes": [].

  2. The mock_services fixture from conftest.py sets workflow_records=None. Added a local mock_services override in the test file that provides a real SqliteWorkflowRecordsStorage(db=db) instance.

All 13 tests now pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants