Files
ComfyUI_frontend/src/services
Dante 67009dcda2 feat(workspace): promote/demote members via Change role menu (FE-770) (#12782)
Promote / demote workspace members ↔ owners in Settings ▸ Members, per
[DES-222 / Figma
2993-15512](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=2993-15512)
and the [permissions section
3343-22966](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3343-22966).

- Fixes
[FE-770](https://linear.app/comfyorg/issue/FE-770/promote-demote-workspace-members-owners-settings-members)
- Stacked on #12759 (`jaewon/fe-768-members-invite-ui`)

## Changes

- Per-member row (…) menu → **Change role** submenu (Owner / Member,
current role check-marked) + existing **Remove member**, replacing the
shared PrimeVue `Menu` with the Reka `DropdownMenu`/`DropdownItem`
(submenu opens right of parent, flips on collision; scalable for future
roles).
- **Make [name] an owner?** / **Demote [name] to member?** confirm
dialogs (single `ChangeMemberRoleDialogContent`, copy 1:1 from Figma).
- `workspaceApi.updateMemberRole` → `PATCH
/api/workspace/members/:userId {role}` +
`teamWorkspaceStore.changeMemberRole` (local role map update; Role
column re-sorts).
- **Original-owner guards** (Figma annotations): creator pinned to the
top of the list, no row actions for anyone on that row; own row also has
no actions. Creator inferred as earliest `joined_at` until BE exposes an
explicit flag (tracked as the FE-770 BE blocker — same applies to the
endpoint itself, which does not exist yet; UI is wired to the proposed
contract).
- `DropdownMenu` raised to `z-3000` so the row menu sits above the
Settings modal (the Reka popper wrapper copies the content's computed
z-index; static `z-1700` lost to dialogs in the `@primeuix` modal
sequence). Also drops the always-rendered icon slot in `DropdownItem` so
icon-less items (Change role / Remove member) align flush-left.

## User stories verified

Viewer = an **owner** (promoted, not the workspace creator), so the
creator guard and the self guard are exercised separately.

| # | Click → action → expected |
| --- | --- |
| US1 | Member row (…) → menu shows **Change role ›** + **Remove
member** |
| US2 | Hover **Change role** → Owner / Member submenu, **current role
check-marked** |
| US3 | Click the current role (✓) → no dialog, no PATCH (no-op) |
| US4 | Member row → **Owner** → "Make {name} an owner?" + "They'll have
the same access as you — managing members, billing, and workspace
settings." + Cancel / **Make owner** |
| US5 | **Cancel** (or ✕) → dialog closes, role unchanged, no PATCH |
| US6 | **Make owner** → `PATCH /api/workspace/members/:id
{role:'owner'}` → Role column → Owner, row **re-sorts under the
creator**, "Role updated" toast, the promoted row keeps its (…) menu |
| US7 | Promoted owner row → **Member** → "Demote {name} to member?" +
"They'll lose admin access." → **Demote to member** → Role column back
to Member |
| US8 | **Creator row (earliest joined) has no (…) button** — even for
another owner |
| US9 | **Own (You) row has no (…) button** — even when not the creator
|
| US10 | PATCH 500 → "Failed to update role" toast, **dialog stays
open**, role unchanged |
| US11 | Viewer with `member` role → no row actions anywhere |
| US12 | **Remove member** → existing FE-768 "Remove this member?"
dialog |

## Tests

Each user story is covered by automated tests and confirmed by a manual
CDP pass driving the real cloud app (mocked auth + boot +
workspace/billing API).

| Story | Unit / Component | E2E (Playwright) | CDP (live app) |
| --- | :---: | :---: | :---: |
| US1 row menu shows Change role + Remove member |  |  |  |
| US2 submenu checkmark follows current role |  |  |  |
| US3 picking the current role is a no-op |  |  |  |
| US4 promote dialog copy (Make owner) |  |  |  |
| US5 Cancel leaves role unchanged, no PATCH |  |  |  |
| US6 Make owner → PATCH, re-sort under creator, toast, stays demotable
|  |  |  |
| US7 demote dialog (Demote to member) → role reverts |  |  |  |
| US8 creator row has no (…) menu |  |  |  |
| US9 own (You) row has no (…) menu |  |  |  |
| US10 PATCH 500 → error toast, dialog stays open |  |  |  |
| US11 member-role viewer sees no row actions |  | — | — |
| US12 Remove member → FE-768 remove dialog |  |  |  |

| Layer | File | What it covers | Result |
| --- | --- | --- | --- |
| E2E (`@cloud`) |
`browser_tests/tests/dialogs/memberRoleChange.spec.ts` | 3 tests — guard
rows (US1/US8/US9/US12), promote→re-sort→demote round trip (US3–US7),
failed PATCH (US10). FE-964 boot pattern: `CloudAuthHelper` +
remote-config flag mock + stateful route mocks capturing PATCH args.
Reka submenu driven via `ArrowRight` (synthetic hover doesn't open it).
| 3 / 3 green |
| Component | `ChangeMemberRoleDialogContent.test.ts` | promote/demote
copy, confirm → store + success toast + close, error keeps dialog open,
cancel | green |
| Component | `MembersPanelContent.test.ts` | creator/self rows hide the
menu (US8/US9), member-viewer gating (US11) | green |
| Composable | `useMembersPanel.test.ts` | menu factory
labels/checkmarks/commands, same-role no-op, creator pin in
`sortMembers`, `isOriginalOwner` | green |
| Store | `teamWorkspaceStore.test.ts` | `changeMemberRole`
success/failure, `originalOwnerId` inference | green |
| CDP live | full cloud app on `local.comfy.org` (mocked auth + boot) |
promote→re-sort→demote round trip with PATCH applied to mock state,
guard rows, submenu checkmark, dialog copy, menu/dialog z-index above
Settings, forced PATCH 500 → error toast | verified |

⚠️ Merge-gated on the BE role-change endpoint (no `PATCH
/workspace/members/:userId` in cloud OpenAPI as of 2026-06-10; see
FE-770 BE-blocker comment).

## Screenshots (local dev, workspace/billing API stubbed; vs Figma
2993-15512)

| Members (before) | Change role submenu |
| --- | --- |
| <img alt="members"
src="https://github.com/user-attachments/assets/686fec86-fcb5-4942-a745-50f367022ab0"
/> | <img alt="submenu"
src="https://github.com/user-attachments/assets/d6adeea8-7001-4c8d-91b7-f5bfc47a50d6"
/> |

| Promote dialog | After promote (Jane → Owner, still demotable) |
Demote dialog |
| --- | --- | --- |
| <img alt="promote"
src="https://github.com/user-attachments/assets/af638cde-2fd6-4c37-b203-78801eeb2785"
/> | <img alt="after"
src="https://github.com/user-attachments/assets/f47dc7af-6b1b-422c-8a9a-5ec889b9af11"
/> | <img alt="demote"
src="https://github.com/user-attachments/assets/9a861d04-a23b-4cd4-bc54-1ed3a66c6429"
/> |
2026-06-25 05:04:48 +00:00
..

Services

This directory contains the service layer for the ComfyUI frontend application. Services encapsulate application logic and functionality into organized, reusable modules.

Table of Contents

Overview

Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.

The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.

Services help organize related functionality into cohesive units, making the codebase more maintainable and testable. By centralizing related operations in services, the application achieves better separation of concerns, with UI components focusing on presentation and services handling functional operations.

Service Architecture

The service layer in ComfyUI follows these architectural principles:

  1. Domain-driven: Each service focuses on a specific domain of the application
  2. Stateless when possible: Services generally avoid maintaining internal state
  3. Reusable: Services can be used across multiple components
  4. Testable: Services are designed for easy unit testing
  5. Isolated: Services have clear boundaries and dependencies

While services can interact with both UI components and stores (centralized state), they primarily focus on implementing functionality rather than managing state. The following diagram illustrates how services fit into the application architecture:

┌─────────────────────────────────────────────────────────┐
│                    UI Components                         │
└────────────────────────────┬────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────┐
│                     Composables                          │
└────────────────────────────┬────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────┐
│                      Services                            │
│                                                         │
│              (Application Functionality)                 │
└────────────────────────────┬────────────────────────────┘
                             │
                 ┌───────────┴───────────┐
                 ▼                       ▼
┌───────────────────────────┐ ┌─────────────────────────┐
│         Stores            │ │       External APIs      │
│    (Centralized State)    │ │                         │
└───────────────────────────┘ └─────────────────────────┘

Core Services

The following table lists ALL services in the system as of 2025-09-01:

Main Services

Service Description Category
audioService.ts Manages audio recording and WAV encoding functionality Media
autoQueueService.ts Manages automatic queue execution Execution
colorPaletteService.ts Handles color palette management and customization UI
comfyManagerService.ts Manages ComfyUI application packages and updates Manager
comfyRegistryService.ts Handles registration and discovery of ComfyUI extensions Registry
customerEventsService.ts Handles customer event tracking and audit logs Analytics
dialogService.ts Provides dialog and modal management UI
extensionService.ts Manages extension registration and lifecycle Extensions
litegraphService.ts Provides utilities for working with the LiteGraph library Graph
load3dService.ts Manages 3D model loading and visualization 3D
mediaCacheService.ts Manages media file caching with blob storage and cleanup Media
newUserService.ts Handles new user initialization and onboarding System
nodeHelpService.ts Provides node documentation and help Nodes
nodeOrganizationService.ts Handles node organization and categorization Nodes
nodeSearchService.ts Implements node search functionality Search
releaseService.ts Manages application release information and updates System
subgraphService.ts Handles subgraph operations and navigation Graph
workflowService.ts Handles workflow operations (save, load, execute) Workflows

Gateway Services

Located in services/gateway/:

Service Description
registrySearchGateway.ts Gateway for registry search operations

Provider Services

Located in services/providers/:

Service Description
algoliaSearchProvider.ts Implements search functionality using Algolia
registrySearchProvider.ts Provides registry search capabilities

Service Development Guidelines

In ComfyUI, services can be implemented using two approaches:

1. Class-based Services

For complex services with state management and multiple methods, class-based services are used:

export class NodeSearchService {
  // Service state
  public readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
  public readonly inputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
  public readonly outputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
  public readonly nodeCategoryFilter: FuseFilter<ComfyNodeDefImpl, string>
  public readonly nodeSourceFilter: FuseFilter<ComfyNodeDefImpl, string>

  constructor(data: ComfyNodeDefImpl[]) {
    // Initialize search index
    this.nodeFuseSearch = new FuseSearch(data, {
      fuseOptions: {
        keys: ['name', 'display_name'],
        includeScore: true,
        threshold: 0.3,
        shouldSort: false,
        useExtendedSearch: true
      },
      createIndex: true,
      advancedScoring: true
    })

    // Setup individual filters
    const fuseOptions = { includeScore: true, threshold: 0.3, shouldSort: true }
    this.inputTypeFilter = new FuseFilter<ComfyNodeDefImpl, string>(data, {
      id: 'input',
      name: 'Input Type',
      invokeSequence: 'i',
      getItemOptions: (node) =>
        Object.values(node.inputs).map((input) => input.type),
      fuseOptions
    })
    // Additional filters initialized similarly...
  }

  public searchNode(
    query: string,
    filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[] = []
  ): ComfyNodeDefImpl[] {
    const matchedNodes = this.nodeFuseSearch.search(query)
    return matchedNodes.filter((node) => {
      return filters.every((filterAndValue) => {
        const { filterDef, value } = filterAndValue
        return filterDef.matches(node, value, { wildcard: '*' })
      })
    })
  }

  get nodeFilters(): FuseFilter<ComfyNodeDefImpl, string>[] {
    return [
      this.inputTypeFilter,
      this.outputTypeFilter,
      this.nodeCategoryFilter,
      this.nodeSourceFilter
    ]
  }
}

2. Composable-style Services

For services that need to integrate with Vue's reactivity system or handle API interactions, we use composable-style services:

export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
  // State (reactive if needed)
  const data = ref(initialData)

  // Search functionality
  function searchNodes(query: string) {
    // Implementation
    return results
  }

  // Additional methods
  function refreshData(newData: ComfyNodeDefImpl[]) {
    data.value = newData
  }

  // Return public API
  return {
    searchNodes,
    refreshData
  }
}

Service Pattern Comparison

Aspect Class-Based Services Composable-Style Services Bootstrap Services Shared State Services
Count 4 services 18+ services 1 service 1 service
Export Pattern export class ServiceName export function useServiceName() export function setupX() export function serviceFactory()
Instantiation new ServiceName(data) useServiceName() Direct function call Direct function call
Best For Complex data structures, search algorithms, expensive initialization Vue integration, API calls, reactive state One-time app initialization Singleton-like shared state
State Management Encapsulated private/public properties External stores + reactive refs Event listeners, side effects Module-level state
Vue Integration Manual integration needed Native reactivity support N/A Varies
Examples NodeSearchService, Load3dService workflowService, dialogService autoQueueService newUserService

Decision Criteria

When choosing between these approaches, consider:

  1. Data Structure Complexity: Classes work well for services managing multiple related data structures (search indices, filters, complex state)
  2. Initialization Cost: Classes are ideal when expensive setup should happen once and be controlled by instantiation
  3. Vue Integration: Composables integrate seamlessly with Vue's reactivity system and stores
  4. API Interactions: Composables handle async operations and API calls more naturally
  5. State Management: Classes provide strong encapsulation; composables work better with external state management
  6. Application Bootstrap: Bootstrap services handle one-time app initialization, event listener setup, and side effects
  7. Singleton Behavior: Shared state services provide module-level state that persists across multiple function calls

Current Usage Patterns:

  • Class-based services (4): Complex data processing, search algorithms, expensive initialization
  • Composable-style services (18+): UI interactions, API calls, store integration, reactive state management
  • Bootstrap services (1): One-time application initialization and event handler setup
  • Shared state services (1): Singleton-like behavior with module-level state management

Service Template

Here's a template for creating a new composable-style service:

/**
 * Service for managing [domain/functionality]
 */
export function useExampleService() {
  // Private state/functionality
  const cache = new Map()

  /**
   * Description of what this method does
   * @param param1 Description of parameter
   * @returns Description of return value
   */
  async function performOperation(param1: string) {
    try {
      // Implementation
      return result
    } catch (error) {
      // Error handling
      console.error(`Operation failed: ${error.message}`)
      throw error
    }
  }

  // Return public API
  return {
    performOperation
  }
}

Common Design Patterns

Services in ComfyUI frequently use the following design patterns:

Caching and Request Deduplication

export function useCachedService() {
  const cache = new Map()
  const pendingRequests = new Map()

  async function fetchData(key: string) {
    // Check cache first
    if (cache.has(key)) return cache.get(key)

    // Check if request is already in progress
    if (pendingRequests.has(key)) {
      return pendingRequests.get(key)
    }

    // Perform new request
    const requestPromise = fetch(`/api/${key}`)
      .then((response) => response.json())
      .then((data) => {
        cache.set(key, data)
        pendingRequests.delete(key)
        return data
      })

    pendingRequests.set(key, requestPromise)
    return requestPromise
  }

  return { fetchData }
}

Factory Pattern

export function useNodeFactory() {
  function createNode(type: string, config: Record<string, any>) {
    // Create node based on type and configuration
    switch (type) {
      case 'basic':
        return {
          /* basic node implementation */
        }
      case 'complex':
        return {
          /* complex node implementation */
        }
      default:
        throw new Error(`Unknown node type: ${type}`)
    }
  }

  return { createNode }
}

Facade Pattern

export function useWorkflowService(apiService, graphService, storageService) {
  // Provides a simple interface to complex subsystems
  async function saveWorkflow(name: string) {
    const graphData = graphService.serializeGraph()
    const storagePath = await storageService.getPath(name)
    return apiService.saveData(storagePath, graphData)
  }

  return { saveWorkflow }
}

Testing Services

Services in ComfyUI can be tested effectively using different approaches depending on their implementation pattern.

Testing Class-Based Services

Setup Requirements:

// Manual instantiation required
const mockData = [
  /* test data */
]
const service = new NodeSearchService(mockData)

Characteristics:

  • Requires constructor argument preparation
  • State is encapsulated within the class instance
  • Direct method calls on the instance
  • Good isolation - each test gets a fresh instance

Example:

describe('NodeSearchService', () => {
  let service: NodeSearchService

  beforeEach(() => {
    const mockNodes = [/* mock node definitions */]
    service = new NodeSearchService(mockNodes)
  })

  test('should search nodes by query', () => {
    const results = service.searchNode('test query')
    expect(results).toHaveLength(2)
  })

  test('should apply filters correctly', () => {
    const filters = [{ filterDef: service.inputTypeFilter, value: 'IMAGE' }]
    const results = service.searchNode('*', filters)
    expect(results.every(node => /* has IMAGE input */)).toBe(true)
  })
})

Testing Composable-Style Services

Setup Requirements:

// Direct function call, no instantiation
const { saveWorkflow, loadWorkflow } = useWorkflowService()

Characteristics:

  • No instantiation needed
  • Integrates naturally with Vue Test Utils
  • Easy mocking of reactive dependencies
  • External store dependencies need mocking

Example:

describe('useWorkflowService', () => {
  beforeEach(() => {
    // Mock external dependencies
    vi.mock('@/stores/settingStore', () => ({
      useSettingStore: () => ({
        get: vi.fn().mockReturnValue(true),
        set: vi.fn()
      })
    }))

    vi.mock('@/stores/toastStore', () => ({
      useToastStore: () => ({
        add: vi.fn()
      })
    }))
  })

  test('should save workflow with prompt', async () => {
    const { saveWorkflow } = useWorkflowService()
    await saveWorkflow('test-workflow')

    // Verify interactions with mocked dependencies
    expect(mockSettingStore.get).toHaveBeenCalledWith('Comfy.PromptFilename')
  })
})

Testing Bootstrap Services

Focus on Setup Behavior:

describe('autoQueueService', () => {
  beforeEach(() => {
    // Mock global dependencies
    vi.mock('@/scripts/api', () => ({
      api: {
        addEventListener: vi.fn()
      }
    }))

    vi.mock('@/scripts/app', () => ({
      app: {
        queuePrompt: vi.fn()
      }
    }))
  })

  test('should setup event listeners', () => {
    setupAutoQueueHandler()

    expect(mockApi.addEventListener).toHaveBeenCalledWith(
      'graphChanged',
      expect.any(Function)
    )
  })

  test('should handle graph changes when auto-queue enabled', () => {
    setupAutoQueueHandler()

    // Simulate graph change event
    const graphChangeHandler = mockApi.addEventListener.mock.calls[0][1]
    graphChangeHandler()

    expect(mockApp.queuePrompt).toHaveBeenCalled()
  })
})

Testing Shared State Services

Focus on Shared State Behavior:

describe('newUserService', () => {
  beforeEach(() => {
    // Reset module state between tests
    vi.resetModules()
  })

  test('should return consistent API across calls', () => {
    const service1 = newUserService()
    const service2 = newUserService()

    // Same functions returned (shared behavior)
    expect(service1.isNewUser).toBeDefined()
    expect(service2.isNewUser).toBeDefined()
  })

  test('should share state between service instances', async () => {
    const service1 = newUserService()
    const service2 = newUserService()

    // Initialize through one instance
    const mockSettingStore = { set: vi.fn() }
    await service1.initializeIfNewUser(mockSettingStore)

    // State should be shared
    expect(service2.isNewUser()).toBe(true) // or false, depending on mock
  })
})

Common Testing Patterns

Mocking External Dependencies:

// Mock stores
vi.mock('@/stores/settingStore', () => ({
  useSettingStore: () => ({
    get: vi.fn(),
    set: vi.fn()
  })
}))

// Mock API calls
vi.mock('@/scripts/api', () => ({
  api: {
    get: vi.fn().mockResolvedValue({ data: 'mock' }),
    post: vi.fn().mockResolvedValue({ success: true })
  }
}))

// Mock Vue composables
vi.mock('vue', () => ({
  ref: vi.fn((val) => ({ value: val })),
  reactive: vi.fn((obj) => obj)
}))

Async Testing:

test('should handle async operations', async () => {
  const service = useMyService()
  const result = await service.performAsyncOperation()
  expect(result).toBeTruthy()
})

test('should handle concurrent requests', async () => {
  const service = useMyService()
  const promises = [service.loadData('key1'), service.loadData('key2')]

  const results = await Promise.all(promises)
  expect(results).toHaveLength(2)
})

Error Handling:

test('should handle service errors gracefully', async () => {
  const service = useMyService()

  // Mock API to throw error
  mockApi.get.mockRejectedValue(new Error('Network error'))

  await expect(service.fetchData()).rejects.toThrow('Network error')
})

test('should provide meaningful error messages', async () => {
  const service = useMyService()
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation()

  await service.handleError('test error')

  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('test error'))
})

Testing Best Practices

  1. Isolate Dependencies: Always mock external dependencies (stores, APIs, DOM)
  2. Reset State: Use beforeEach to ensure clean test state
  3. Test Error Paths: Don't just test happy paths - test error scenarios
  4. Mock Timers: Use vi.useFakeTimers() for time-dependent services
  5. Test Async Properly: Use async/await and proper promise handling

For more detailed information about the service layer pattern and its applications, refer to: