Compare commits

..

8 Commits

Author SHA1 Message Date
huang47
6e0af560cc feat: filter disabled partner nodes from discovery 2026-07-17 02:00:29 -07:00
huang47
cbdacb3316 feat: add partner node enforcement control 2026-07-17 01:32:55 -07:00
huang47
4e27f203c9 fix: address allowlist review feedback 2026-07-17 01:25:48 -07:00
huang47
9459aa1b04 feat: add partner node allowlist editor 2026-07-17 00:49:05 -07:00
huang47
71f37c4daf test: cover governance eligibility gates 2026-07-17 00:00:59 -07:00
huang47
8938305de7 test: use real policy responses 2026-07-16 23:55:05 -07:00
huang47
a4ffecd49d feat: add partner node governance foundation 2026-07-16 23:52:11 -07:00
AustinMroz
3be998aaf4 Add indicator for free tier quota system (#13657)
Adds frontend support for a quota based free tier.
- Adds a indicator component beneath the run button (docked/undocked/app
mode)
- Tracks available usages as workflows are queued. This is enforced by
backend, but updated quota information is not sent after initial load.
- Tracks partner node presence in graph (Workflows with partner nodes
are not allowed under the quota system)

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/c8dd53c3-9eff-4712-a4c2-c3d31bbbae35"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c9410799-130d-4332-b2f8-20e993402f2c"
/>|

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-07-17 04:25:26 +00:00
61 changed files with 6861 additions and 5126 deletions

View File

@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -63,7 +63,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -121,7 +121,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -28,7 +28,7 @@ jobs:
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: read

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,7 +14,7 @@ jobs:
update-screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: write
@@ -128,9 +128,6 @@ jobs:
- name: Remove label
if: always() && github.event_name == 'pull_request'
# Label cleanup is best-effort; the restricted pull_request token may
# lack permission to remove labels and must not fail the run.
continue-on-error: true
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}

View File

@@ -8,11 +8,13 @@ export class ComfyActionbar {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
public readonly propertiesButton: Locator
public readonly dragHandle: Locator
constructor(public readonly page: Page) {
this.root = page.locator('.actionbar-container')
this.queueButton = new ComfyQueueButton(this)
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
this.dragHandle = this.root.locator('.drag-handle')
}
async isDocked() {

View File

@@ -0,0 +1,21 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class FreeTierQuota {
readonly root: Locator
constructor(comfyPage: ComfyPage) {
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
}
async getMax() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[2]
}
async getAvailable() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[1]
}
}

View File

@@ -17,7 +17,9 @@ import { mockSystemStats } from '../fixtures/data/systemStats'
// Extend the base set with test-specific nodes
const nodeDefs = createMockNodeDefinitions({
MyCustomNode: {/* ... */}
MyCustomNode: {
/* ... */
}
})
await page.route('**/api/object_info', (route) =>

View File

@@ -103,7 +103,8 @@ export const TestIds = {
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
actionBarButtons: 'action-bar-buttons',
freeTierQuota: 'free-tier-quota'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'

View File

@@ -0,0 +1,126 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function partnerNode(
name: string,
displayName: string,
provider: string
): ComfyNodeDef {
return {
name,
display_name: displayName,
category: `partner/image/${provider}`,
python_module: `comfy_api_nodes.${provider.toLocaleLowerCase()}`,
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true
}
}
async function openAllowlist(page: Page) {
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
const content = dialog.getByRole('main')
await content.getByRole('tab', { name: 'Allowlist' }).click()
await expect(
content.getByRole('heading', { name: 'Partner nodes' })
).toBeVisible()
return content
}
test.describe('Partner node allowlist', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
test('saves enforcement and allowlist as one policy', async ({ page }) => {
await new CloudWorkspaceMockHelper(page).setup()
await page.route('**/api/features', (route) =>
route.fulfill(
jsonRoute({
...WORKSPACE_FEATURE_FLAG,
partner_node_governance_enabled: true
} satisfies RemoteConfig)
)
)
await page.route('**/api/object_info', (route) =>
route.fulfill(
jsonRoute({
FluxFill: partnerNode('FluxFill', 'Flux Fill', 'BFL'),
FluxExpand: partnerNode('FluxExpand', 'Flux Expand', 'BFL'),
VeoVideo: partnerNode('VeoVideo', 'Veo Video', 'Google')
})
)
)
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
route.fulfill(
jsonRoute({
assets: [],
total: 0,
has_more: false
} satisfies ListAssetsResponse)
)
)
let policy: PartnerNodePolicyResponse = {
enforcement_enabled: false,
nodes: { FluxFill: false, FluxExpand: true, VeoVideo: true }
} satisfies PartnerNodePolicyResponse
const updates: unknown[] = []
await page.route('**/api/workspace/partner-node-policy', (route) => {
if (route.request().method() === 'PUT') {
policy = route.request().postDataJSON() as PartnerNodePolicyResponse
updates.push(policy)
}
return route.fulfill(jsonRoute(policy))
})
const content = await openAllowlist(page)
const fluxFill = content.getByRole('switch', { name: 'Allow Flux Fill' })
await expect(fluxFill).not.toBeChecked()
await expect(
content.getByRole('switch', { name: 'Allow Flux Expand' })
).toBeChecked()
await content
.getByRole('switch', { name: 'Enforce partner node allowlist' })
.click()
await fluxFill.click()
await content.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('Partner node policy saved')).toBeVisible()
expect(updates).toEqual([
{
enforcement_enabled: true,
nodes: { FluxFill: true, FluxExpand: true, VeoVideo: true }
}
] satisfies PartnerNodePolicyResponse[])
})
})

View File

@@ -0,0 +1,63 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
test.beforeEach(async ({ page }) => {
const features = {
free_tier_job_allowance_enabled: true,
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
}
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
})
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
const freeTierQuota = new FreeTierQuota(comfyPage)
await test.step('Populates initial state from config', async () => {
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
expect(await freeTierQuota.getMax()).toBe('5')
})
await test.step('available decrements on run', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('connects to detached run button', async () => {
const handle = comfyPage.actionbar.dragHandle
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
expect(await freeTierQuota.getAvailable()).toBe('2')
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
})
await test.step('Detects workflows with Partner nodes', async () => {
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await node.delete()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('Does not decrease past 0', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await execution.run()
await execution.run()
await execution.run()
await comfyPage.nextFrame()
expect(await freeTierQuota.getAvailable()).toBe(undefined)
})
})
})

View File

@@ -0,0 +1,97 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function partnerNode(name: string): ComfyNodeDef {
return {
name,
display_name: name,
category: 'partner/image/Acme',
python_module: 'comfy_api_nodes.acme',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true
}
}
async function setupGovernedWorkspace(page: Page) {
await new CloudWorkspaceMockHelper(page).setup()
await page.route('**/api/features', (route) =>
route.fulfill(
jsonRoute({
...WORKSPACE_FEATURE_FLAG,
partner_node_governance_enabled: true
} satisfies RemoteConfig)
)
)
await page.route('**/api/object_info', (route) =>
route.fulfill(
jsonRoute({
AllowedPartnerNode: partnerNode('AllowedPartnerNode'),
DisabledPartnerNode: partnerNode('DisabledPartnerNode')
})
)
)
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
route.fulfill(
jsonRoute({
assets: [],
total: 0,
has_more: false
} satisfies ListAssetsResponse)
)
)
await page.route('**/api/workspace/partner-node-policy', (route) =>
route.fulfill(
jsonRoute({
enforcement_enabled: true,
nodes: {
AllowedPartnerNode: true,
DisabledPartnerNode: false
}
} satisfies PartnerNodePolicyResponse)
)
)
}
test.describe('Partner node governance discovery', { tag: '@cloud' }, () => {
test('hides disabled nodes from search', async ({ page }) => {
await setupGovernedWorkspace(page)
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page.evaluate(async () => {
await window.app!.extensionManager.setting.set(
'Comfy.NodeSearchBoxImpl',
'default'
)
await window.app!.extensionManager.command.execute(
'Workspace.SearchBox.Toggle'
)
})
const search = page.getByRole('search')
await expect(search).toBeVisible()
await search.getByRole('combobox').fill('PartnerNode')
await expect(search.getByText('AllowedPartnerNode')).toBeVisible()
await expect(search.getByText('DisabledPartnerNode')).toHaveCount(0)
})
})

View File

@@ -31,7 +31,9 @@ export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {/* props */}
args: {
/* props */
}
}
```

View File

@@ -37,8 +37,10 @@ const config: KnipConfig = {
entry: ['src/scripts/**/*.ts']
}
},
ignoreBinaries: ['python3'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',

View File

@@ -97,7 +97,7 @@
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.3.0",
"@vueuse/router": "^14.2.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
@@ -111,7 +111,7 @@
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "catalog:",
"fuse.js": "7.0.0",
"fuse.js": "^7.0.0",
"glob": "catalog:",
"jsonata": "catalog:",
"loglevel": "^1.9.2",
@@ -219,5 +219,5 @@
"node": ">=25 <26",
"pnpm": ">=11.3"
},
"packageManager": "pnpm@11.13.1"
"packageManager": "pnpm@11.3.0"
}

View File

@@ -78,9 +78,9 @@
--color-interface-panel-job-progress-border: var(--base-foreground);
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b / 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b / 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b / 0.4);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b/ 0.4);
--color-alpha-charcoal-600-30: color-mix(
in srgb,
var(--color-charcoal-600) 30%,
@@ -107,7 +107,7 @@
--color-highlight: var(--p-primary-color);
/* Special Colors (temporary) */
--color-dark-elevation-1-5: rgba(from white r g b / 0.015);
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@@ -1873,9 +1873,9 @@ audio.comfy-audio.empty-audio-widget {
/* Shadow Cover TOP */
linear-gradient(--value(--color-*) 30%, transparent) center top,
/* Shadow Cover BOTTOM */
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
/* Shadow TOP */
radial-gradient(
radial-gradient(
farthest-side at 50% 0,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,
@@ -1883,7 +1883,7 @@ audio.comfy-audio.empty-audio-widget {
)
center top,
/* Shadow BOTTOM */
radial-gradient(
radial-gradient(
farthest-side at 50% 100%,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,

9463
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,26 +24,26 @@ catalog:
'@iconify/tailwind4': ^1.2.3
'@iconify/tools': ^5.0.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.1
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@lobehub/i18n-cli': ^1.26.1
'@lucide/vue': ^1.17.0
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.61.1
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
'@primevue/core': 4.2.5
'@primevue/forms': 4.2.5
'@primevue/core': ^4.2.5
'@primevue/forms': ^4.2.5
'@primevue/icons': 4.2.5
'@primevue/themes': 4.2.5
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^2.1.0
'@storybook/addon-docs': ^10.5.0
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.5.0
'@storybook/vue3-vite': ^10.5.0
'@tailwindcss/vite': ^4.3.2
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.3.0
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
@@ -64,11 +64,11 @@ catalog:
'@types/three': ^0.184.1
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.8
'@vitest/coverage-v8': ^4.1.10
'@vitest/ui': ^4.1.10
'@vueuse/core': ^14.3.0
'@vueuse/integrations': ^14.3.0
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^6.4.2
@@ -76,75 +76,75 @@ catalog:
class-variance-authority: ^0.7.1
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: 3.4.7
dompurify: ^3.4.5
dotenv: ^16.4.5
eslint: ^10.7.0
eslint: ^10.4.0
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.5
eslint-plugin-better-tailwindcss: ^4.6.1
eslint-plugin-import-x: ^4.17.1
eslint-plugin-oxlint: 1.73.0
eslint-plugin-playwright: ^2.10.5
eslint-plugin-storybook: ^10.5.0
eslint-plugin-testing-library: ^7.16.2
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.69.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.2
eslint-plugin-vue: ^10.9.1
fallow: ^2.102.0
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: 20.9.0
happy-dom: ^20.8.9
husky: ^9.1.7
jiti: 2.7.0
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.27.0
knip: ^6.15.0
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
minisearch: ^7.2.0
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.59.0
oxlint: ^1.74.0
oxlint-tsgolint: ^0.24.0
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint-tsgolint: ^0.23.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
posthog-js: ^1.358.1
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: 4.2.5
primevue: ^4.2.5
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.5.0
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.2
tailwindcss: ^4.3.0
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.64.0
typescript-eslint: ^8.60.0
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.1.4
vite: ^8.0.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.1.5
vitest: ^4.1.10
vue: ^3.5.39
vue-component-type-helpers: ^3.3.7
vue-eslint-parser: ^10.4.1
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.1.0
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
vue-router: ^4.4.3
vue-tsc: ^3.3.7
vue-tsc: ^3.2.5
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27

View File

@@ -32,11 +32,11 @@ let _runWhenIdle: (
* Falls back to setTimeout-based scheduling in browsers without native support.
*/
export let runWhenGlobalIdle: (
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
// Self-invoking function to set up the idle callback implementation
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: GlobalWindow = globalThis as GlobalWindow

View File

@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer!.classList).toContain('px-2')
expect(actionbarContainer!.classList).not.toContain('w-0')
} finally {
unmount()
vi.unstubAllGlobals()

View File

@@ -11,7 +11,7 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div class="flex items-start gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
@@ -34,61 +34,75 @@
</Button>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
>
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container relative flex items-center gap-2',
isActionbarContainerEmpty &&
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
/>
</div>
</div>
<FreeTierQuota v-if="!isActionbarFloating" />
</div>
</div>
<ErrorOverlay />
@@ -147,6 +161,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -209,21 +224,6 @@ const hasDockedButtons = computed(() => {
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
return cn(base, 'px-2', 'border-interface-stroke')
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)

View File

@@ -75,6 +75,7 @@
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
<FreeTierQuota v-if="!isDocked" />
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
@@ -109,6 +110,7 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'

View File

@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockIsActiveSubscription = ref(true)
const mockCanRunWorkflows = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription
canRunWorkflows: mockCanRunWorkflows
})
}))
@@ -32,7 +32,7 @@ function renderWrapper() {
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockIsActiveSubscription.value = true
mockCanRunWorkflows.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
})
it('locks the run button when the subscription is inactive', () => {
mockIsActiveSubscription.value = false
mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockIsActiveSubscription.value = false
mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockIsActiveSubscription.value = true
mockCanRunWorkflows.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()

View File

@@ -1,7 +1,7 @@
<template>
<component
:is="currentButton"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
:key="canRunWorkflows ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
const { isActiveSubscription } = useBillingContext()
const { canRunWorkflows } = useBillingContext()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -120,4 +120,5 @@ export interface BillingContext extends BillingState, BillingActions {
*/
isTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
canRunWorkflows: ComputedRef<boolean>
}

View File

@@ -6,6 +6,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
@@ -129,6 +130,16 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const freeTierQuota = useFreeTierQuota()
const canRunWorkflows = computed(
() =>
isActiveSubscription.value &&
(!isFreeTier.value ||
!freeTierQuota.quotaEnabled.value ||
freeTierQuota.freeTierExecutionPermitted.value)
)
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
@@ -312,6 +323,7 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
canRunWorkflows,
isFreeTier,
isLegacyTeamPlan,
isTeamPlan,

View File

@@ -1,12 +1,19 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, toValue } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: SubgraphInput
@@ -150,3 +157,20 @@ export const usePriceBadge = () => {
updateSubgraphCredits
}
}
export const useCreditsBadgesInGraph = createSharedComposable(() => {
const { isCreditsBadge } = usePriceBadge()
const vueNodeLifecycle = useVueNodeLifecycle()
return computed(() => {
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
if (!app.graph) return []
return mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
})
})

View File

@@ -1,8 +1,17 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockWriteText = vi.fn()
const mockCopy = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@vueuse/core', () => ({
useClipboard: vi.fn(() => ({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true)
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
@@ -13,31 +22,34 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import { useClipboard } from '@vueuse/core'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
describe('useCopyToClipboard', () => {
beforeEach(() => {
vi.resetAllMocks()
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: mockWriteText }
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true),
text: ref('')
})
})
it('shows success toast when modern clipboard succeeds', async () => {
mockWriteText.mockResolvedValue(undefined)
mockCopy.mockResolvedValue(undefined)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockWriteText).toHaveBeenCalledWith('hello')
expect(mockCopy).toHaveBeenCalledWith('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('falls back to legacy when modern clipboard fails', async () => {
mockWriteText.mockRejectedValue(new Error('Not allowed'))
mockCopy.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
@@ -50,7 +62,7 @@ describe('useCopyToClipboard', () => {
})
it('shows error toast when both modern and legacy fail', async () => {
mockWriteText.mockRejectedValue(new Error('Not allowed'))
mockCopy.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => false)
const { copyToClipboard } = useCopyToClipboard()
@@ -61,17 +73,19 @@ describe('useCopyToClipboard', () => {
)
})
it('falls through to legacy when clipboard API is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined
it('falls through to legacy when isSupported is false', async () => {
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => false),
text: ref('')
})
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockWriteText).not.toHaveBeenCalled()
expect(mockCopy).not.toHaveBeenCalled()
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })

View File

@@ -1,3 +1,4 @@
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
@@ -19,14 +20,15 @@ function legacyCopy(text: string): boolean {
}
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const toast = useToast()
async function copyToClipboard(text: string) {
let success = false
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text)
if (isSupported.value) {
await copy(text)
success = true
}
} catch {

View File

@@ -182,6 +182,20 @@ describe('useFeatureFlags', () => {
})
})
describe('partnerNodeGovernanceEnabled', () => {
afterEach(() => {
remoteConfig.value = {}
})
it('uses the workspace eligibility flag', () => {
remoteConfig.value = { partner_node_governance_enabled: true }
const { flags } = useFeatureFlags()
expect(flags.partnerNodeGovernanceEnabled).toBe(true)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -24,6 +24,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
@@ -33,6 +34,7 @@ export enum ServerFeatureFlag {
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -133,6 +135,13 @@ export function useFeatureFlags() {
cachedTeamWorkspacesEnabled
)
},
get partnerNodeGovernanceEnabled() {
return resolveFlag(
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
remoteConfig.value.partner_node_governance_enabled,
false
)
},
get userSecretsEnabled() {
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,
@@ -202,6 +211,16 @@ export function useFeatureFlags() {
cachedBillingControlEnabled
)
},
get freeTierJobAllowanceEnabled() {
const config = remoteConfig.value as typeof remoteConfig.value & {
free_tier_job_allowance_enabled?: boolean
}
return resolveFlag(
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
config.free_tier_job_allowance_enabled,
false
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,

View File

@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const registerExtension = vi.hoisted(() => vi.fn())
const usePartnerNodeGovernanceStore = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension })
}))
describe('cloudPartnerNodeGovernance', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
it('initializes governance during Cloud setup', async () => {
await import('./cloudPartnerNodeGovernance')
expect(registerExtension).toHaveBeenCalledOnce()
const extension = registerExtension.mock.calls[0]?.[0] as {
name: string
setup: () => void
}
expect(extension.name).toBe('Comfy.Cloud.PartnerNodeGovernance')
extension.setup()
expect(usePartnerNodeGovernanceStore).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,10 @@
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.Cloud.PartnerNodeGovernance',
setup: () => {
usePartnerNodeGovernanceStore()
}
})

View File

@@ -38,6 +38,7 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./cloudPartnerNodeGovernance')
}
// Feedback button for cloud and nightly builds

View File

@@ -2919,7 +2919,27 @@
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
"membersCount": "Members ({count})",
"allowlist": "Allowlist"
},
"allowlist": {
"title": "Partner nodes",
"description": "Choose which partner nodes are allowed when workspace enforcement is enabled.",
"searchPlaceholder": "Search partner nodes...",
"loading": "Loading partner node policy...",
"loadError": "Partner node policy could not be loaded.",
"retry": "Try again",
"empty": "No partner nodes are available.",
"noMatches": "No partner nodes match your search.",
"allowedCount": "{enabled} of {total} allowed",
"nodeToggle": "Allow {name}",
"saved": "Partner node policy saved",
"saveError": "Partner node policy could not be saved.",
"enforcement": {
"title": "Enforce partner node allowlist",
"description": "Block partner nodes that are not explicitly allowed.",
"toggle": "Enforce partner node allowlist"
}
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
@@ -3587,6 +3607,9 @@
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback",
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
"freeTierRunsExhausted": "No runs left",
"freeTierPartner": "Partner nodes need a paid plan",
"share": "Share",
"shareTooltip": "Share workflow"
},

View File

@@ -19,9 +19,7 @@ export const hasNonEmptyValue = (
return true
}
export const isOtherValue = (
current: string | string[] | undefined
): boolean =>
export const isOtherValue = (current: string | string[] | undefined): boolean =>
Array.isArray(current) ? current.includes('other') : current === 'other'
const conditionMatches = (

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
const DOT_COLORS = [
'bg-destructive-background',
'bg-warning-background',
'bg-success-background'
]
const { showSubscriptionDialog } = useBillingContext()
const { t } = useI18n()
const { available, hasInvalidNodes, maxAvailable, quotaEnabled } =
useFreeTierQuota()
const dotColor = computed(() => {
const ratio = maxAvailable.value ? available.value / maxAvailable.value : 0
return DOT_COLORS[
Math.min(Math.floor(ratio * DOT_COLORS.length), DOT_COLORS.length - 1)
]
})
const label = computed(() =>
available.value === 0
? t('actionbar.freeTierRunsExhausted')
: t('actionbar.freeTierRuns', {
available: available.value,
MAX_AVAILABLE: maxAvailable.value
})
)
</script>
<template>
<div
v-if="quotaEnabled"
class="mt-2 w-full cursor-pointer border-t border-border-subtle bg-comfy-menu-bg px-4 pt-2 select-none"
data-testid="free-tier-quota"
@click="showSubscriptionDialog({ reason: 'free_tier_quota' })"
>
<div
v-if="hasInvalidNodes"
class="flex w-full items-center justify-center gap-2"
>
<i class="icon-[comfy--credits] bg-amber-400" />
{{ t('actionbar.freeTierPartner') }}
</div>
<div v-else class="flex w-full items-center justify-between">
<div class="flex gap-2" :aria-label="label" role="img">
<div
v-for="index in maxAvailable"
:key="index"
:class="
cn(
'size-1.5 rounded-full',
index > available ? 'bg-secondary-background-selected' : dotColor
)
"
/>
</div>
<div v-text="label" />
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
export const useFreeTierQuota = createSharedComposable(function () {
const { flags } = useFeatureFlags()
const creditsBadges = useCreditsBadgesInGraph()
const available = ref(0)
const maxAvailable = ref(0)
watch(
() => remoteConfig.value.free_tier_balance?.remaining,
(val) => (available.value = val ?? 0),
{ immediate: true }
)
watch(
() => remoteConfig.value.free_tier_balance?.allowance,
(val) => (maxAvailable.value = val ?? 0),
{ immediate: true }
)
const quotaEnabled = computed(
() => flags.freeTierJobAllowanceEnabled && maxAvailable.value > 0
)
const hasInvalidNodes = computed(() => creditsBadges.value.length > 0)
const freeTierExecutionPermitted = computed(
() => !hasInvalidNodes.value && quotaEnabled.value && available.value > 0
)
function trackRun() {
if (available.value > 0) available.value--
}
return {
available,
freeTierExecutionPermitted,
hasInvalidNodes,
maxAvailable,
quotaEnabled,
trackRun
}
})

View File

@@ -107,9 +107,15 @@ export type RemoteConfig = {
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
partner_node_governance_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number
free_tier_balance?: {
allowance: number
used: number
remaining: number
}
new_free_tier_subscriptions?: boolean
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean

View File

@@ -33,6 +33,7 @@ export type PaymentIntentSource =
| 'invite_member_upsell'
| 'upload_model_upgrade'
| 'team_upgrade_resume'
| 'free_tier_quota'
export type SubscriptionCheckoutType = 'new' | 'change'
export type SubscriptionCheckoutTier = TierKey | 'team'

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError,
updatePartnerNodePolicy
} from '@/platform/workspace/api/partnerNodePolicyApi'
const mockFetchApi = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
}
}))
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), init)
}
describe('partnerNodePolicyApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('normalizes the configured policy response', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({
enforcement_enabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
})
)
await expect(getPartnerNodePolicy()).resolves.toEqual({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
})
expect(mockFetchApi).toHaveBeenCalledWith(
'/workspace/partner-node-policy',
{ cache: 'no-store' }
)
})
it('maps 404 to an unconfigured policy', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 404, statusText: 'Not Found' })
)
await expect(getPartnerNodePolicy()).resolves.toBeNull()
})
it('preserves non-404 status codes for policy decisions', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 503, statusText: 'Service Unavailable' })
)
await expect(getPartnerNodePolicy()).rejects.toEqual(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
})
it('rejects malformed policy responses', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({ enforcement_enabled: 'yes', nodes: [] })
)
await expect(getPartnerNodePolicy()).rejects.toMatchObject({
name: 'ZodError'
})
})
it('serializes and validates a whole-policy update', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({
enforcement_enabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
)
await expect(
updatePartnerNodePolicy({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
).resolves.toEqual({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
expect(mockFetchApi).toHaveBeenCalledWith(
'/workspace/partner-node-policy',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enforcement_enabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
}
)
})
it('preserves update failures for the editor', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 409, statusText: 'Conflict' })
)
await expect(
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toEqual(new PartnerNodePolicyApiError(409, 'Conflict'))
})
it('rejects malformed update responses', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({ enforcement_enabled: false, nodes: [] })
)
await expect(
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toMatchObject({ name: 'ZodError' })
})
})

View File

@@ -0,0 +1,67 @@
import { z } from 'zod'
import { api } from '@/scripts/api'
const PARTNER_NODE_POLICY_PATH = '/workspace/partner-node-policy'
const partnerNodePolicyResponseSchema = z.object({
enforcement_enabled: z.boolean(),
nodes: z.record(z.string(), z.boolean())
})
export type PartnerNodePolicyResponse = z.infer<
typeof partnerNodePolicyResponseSchema
>
export interface PartnerNodePolicy {
enforcementEnabled: boolean
nodes: Readonly<Record<string, boolean>>
}
export class PartnerNodePolicyApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message)
this.name = 'PartnerNodePolicyApiError'
}
}
function parsePartnerNodePolicy(data: unknown): PartnerNodePolicy {
const policy = partnerNodePolicyResponseSchema.parse(data)
return {
enforcementEnabled: policy.enforcement_enabled,
nodes: policy.nodes
}
}
function throwResponseError(response: Response): never {
throw new PartnerNodePolicyApiError(response.status, response.statusText)
}
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
cache: 'no-store'
})
if (response.status === 404) return null
if (!response.ok) throwResponseError(response)
return parsePartnerNodePolicy(await response.json())
}
export async function updatePartnerNodePolicy(
policy: PartnerNodePolicy
): Promise<PartnerNodePolicy> {
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enforcement_enabled: policy.enforcementEnabled,
nodes: policy.nodes
})
})
if (!response.ok) throwResponseError(response)
return parsePartnerNodePolicy(await response.json())
}

View File

@@ -0,0 +1,270 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { defineComponent, nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicyStatus } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import PartnerNodeAllowlistPanel from './PartnerNodeAllowlistPanel.vue'
const mockSavePolicy = vi.fn()
const mockLoadPolicy = vi.fn()
const mockToastAdd = vi.fn()
const state = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
governedWorkspaceId: ref<string | null>('workspace-one'),
partnerNodes: ref([
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
]),
policy: ref<PartnerNodePolicy | null>({
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}),
status: ref<PartnerNodePolicyStatus>('configured')
}
})
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore: () => ({
...state,
savePolicy: mockSavePolicy,
loadPolicy: mockLoadPolicy
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
const ToggleSwitchStub = defineComponent({
name: 'ToggleSwitch',
props: {
modelValue: Boolean,
disabled: Boolean,
ariaLabel: String
},
emits: ['update:modelValue'],
template: `
<button
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
@click="$emit('update:modelValue', !modelValue)"
/>
`
})
const SearchInputStub = defineComponent({
name: 'SearchInput',
props: {
modelValue: { type: String, required: true },
placeholder: String,
disabled: Boolean
},
emits: ['update:modelValue'],
template: `
<input
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderPanel() {
return render(PartnerNodeAllowlistPanel, {
global: {
plugins: [i18n],
stubs: {
ToggleSwitch: ToggleSwitchStub,
SearchInput: SearchInputStub
}
}
})
}
describe('PartnerNodeAllowlistPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
state.governedWorkspaceId.value = 'workspace-one'
state.partnerNodes.value = [
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
]
state.policy.value = {
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}
state.status.value = 'configured'
mockSavePolicy.mockResolvedValue(true)
})
it('groups catalog nodes and reflects the configured allowlist', () => {
renderPanel()
expect(screen.getByText('BFL')).toBeInTheDocument()
expect(screen.getByText('Google')).toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
).toBeChecked()
expect(
screen.getByRole('switch', { name: 'Allow Flux Fill' })
).not.toBeChecked()
expect(
screen.getByRole('switch', { name: 'Allow Flux Expand' })
).toBeChecked()
expect(screen.getByText('1 of 2 allowed')).toBeInTheDocument()
})
it('saves one whole policy while preserving enforcement and hidden entries', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockSavePolicy).toHaveBeenCalledWith({
enforcementEnabled: true,
nodes: {
FluxFill: true,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
})
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Partner node policy saved',
life: 2000
})
})
it('saves enforcement with the existing allowlist', async () => {
const user = userEvent.setup()
state.policy.value = {
enforcementEnabled: false,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}
renderPanel()
await user.click(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockSavePolicy).toHaveBeenCalledWith({
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
})
})
it('treats an unconfigured workspace as allow-all draft', async () => {
state.policy.value = null
state.status.value = 'unconfigured'
renderPanel()
await nextTick()
expect(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
).not.toBeChecked()
const nodeToggles = screen.getAllByRole('switch', { name: /^Allow / })
expect(nodeToggles).toHaveLength(3)
for (const toggle of nodeToggles) {
expect(toggle).toBeChecked()
}
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('filters by provider and node identity', async () => {
const user = userEvent.setup()
renderPanel()
await user.type(
screen.getByPlaceholderText('Search partner nodes...'),
'veo'
)
expect(screen.getByText('Veo Video')).toBeInTheDocument()
expect(screen.queryByText('Flux Fill')).not.toBeInTheDocument()
expect(screen.getByText('Google')).toBeInTheDocument()
})
it('offers retry when the policy is unavailable', async () => {
const user = userEvent.setup()
state.status.value = 'unavailable'
renderPanel()
await user.click(screen.getByRole('button', { name: 'Try again' }))
expect(mockLoadPolicy).toHaveBeenCalledOnce()
expect(screen.getByRole('alert')).toHaveTextContent(
'Partner node policy could not be loaded.'
)
})
it('keeps draft changes and surfaces a save failure', async () => {
const user = userEvent.setup()
mockSavePolicy.mockRejectedValue(new Error('Conflict'))
renderPanel()
const toggle = screen.getByRole('switch', { name: 'Allow Flux Fill' })
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(toggle).toBeChecked()
expect(screen.getByRole('alert')).toHaveTextContent(
'Partner node policy could not be saved.'
)
})
})

View File

@@ -0,0 +1,286 @@
<template>
<div class="grow overflow-auto pt-6">
<div
class="flex size-full min-h-0 flex-col gap-5 rounded-2xl border border-interface-stroke p-6"
>
<div class="flex items-start gap-6">
<div class="min-w-0 flex-1">
<h2 class="m-0 text-base font-semibold text-base-foreground">
{{ $t('workspacePanel.allowlist.title') }}
</h2>
<p class="mt-1 mb-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.allowlist.description') }}
</p>
</div>
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.allowlist.searchPlaceholder')"
size="lg"
class="w-64 shrink-0"
:disabled="!isReady"
/>
</div>
<div
v-if="isReady"
class="flex min-h-14 items-center gap-4 rounded-xl border border-interface-stroke/60 px-4 py-3"
>
<div class="min-w-0 flex-1">
<div class="text-sm text-base-foreground">
{{ $t('workspacePanel.allowlist.enforcement.title') }}
</div>
<div class="mt-0.5 text-xs text-muted-foreground">
{{ $t('workspacePanel.allowlist.enforcement.description') }}
</div>
</div>
<ToggleSwitch
:model-value="draftEnforcementEnabled"
:disabled="isSaving"
:aria-label="$t('workspacePanel.allowlist.enforcement.toggle')"
@update:model-value="setEnforcementEnabled"
/>
</div>
<div
v-if="status === 'loading'"
role="status"
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
>
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
{{ $t('workspacePanel.allowlist.loading') }}
</div>
<div
v-else-if="status === 'error' || status === 'unavailable'"
role="alert"
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
>
<span>{{ $t('workspacePanel.allowlist.loadError') }}</span>
<Button variant="muted-textonly" @click="loadPolicy">
{{ $t('workspacePanel.allowlist.retry') }}
</Button>
</div>
<div
v-else-if="isReady && partnerNodes.length === 0"
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.allowlist.empty') }}
</div>
<div v-else-if="isReady" class="min-h-0 flex-1 overflow-y-auto">
<div
v-if="groups.length === 0"
class="flex min-h-32 items-center justify-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.allowlist.noMatches') }}
</div>
<template v-else>
<section
v-for="group in groups"
:key="group.provider"
class="mb-5 last:mb-0"
>
<div
class="mb-2 flex items-center justify-between text-xs font-medium text-muted-foreground"
>
<span>{{ group.provider }}</span>
<span>
{{
$t('workspacePanel.allowlist.allowedCount', {
enabled: group.enabledCount,
total: group.nodes.length
})
}}
</span>
</div>
<div
class="overflow-hidden rounded-xl border border-interface-stroke/60"
>
<div
v-for="node in group.nodes"
:key="node.id"
class="flex min-h-14 items-center gap-4 border-b border-interface-stroke/40 px-4 last:border-b-0"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-base-foreground">
{{ node.name }}
</div>
<div
v-if="node.name !== node.id"
class="truncate text-xs text-muted-foreground"
>
{{ node.id }}
</div>
</div>
<ToggleSwitch
:model-value="draftNodes[node.id]"
:disabled="isSaving"
:aria-label="
$t('workspacePanel.allowlist.nodeToggle', {
name: node.name
})
"
@update:model-value="
(enabled: boolean) => setNodeEnabled(node.id, enabled)
"
/>
</div>
</div>
</section>
</template>
</div>
<div v-if="isReady" class="flex min-h-10 items-center justify-end gap-3">
<span v-if="saveError" role="alert" class="text-destructive text-sm">
{{ $t('workspacePanel.allowlist.saveError') }}
</span>
<Button
variant="primary"
size="lg"
:disabled="!hasChanges"
:loading="isSaving"
@click="save"
>
{{ $t('g.save') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import type { PartnerNodeCatalogItem } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
interface PartnerNodeGroup {
provider: string
nodes: PartnerNodeCatalogItem[]
enabledCount: number
}
const { t } = useI18n()
const governanceStore = usePartnerNodeGovernanceStore()
const { governedWorkspaceId, partnerNodes, policy, status } =
storeToRefs(governanceStore)
const toastStore = useToastStore()
const searchQuery = ref('')
const draftNodes = ref<Record<string, boolean>>({})
const draftEnforcementEnabled = ref(false)
const isSaving = ref(false)
const saveError = ref(false)
const isReady = computed(
() => status.value === 'configured' || status.value === 'unconfigured'
)
function originalNodeValue(nodeId: string): boolean {
return policy.value ? policy.value.nodes[nodeId] === true : true
}
function resetDraft(): void {
draftEnforcementEnabled.value = policy.value?.enforcementEnabled ?? false
draftNodes.value = Object.fromEntries(
partnerNodes.value.map((node) => [node.id, originalNodeValue(node.id)])
)
saveError.value = false
}
watch([governedWorkspaceId, partnerNodes, policy], resetDraft, {
immediate: true
})
const hasChanges = computed(
() =>
draftEnforcementEnabled.value !==
(policy.value?.enforcementEnabled ?? false) ||
partnerNodes.value.some(
(node) => draftNodes.value[node.id] !== originalNodeValue(node.id)
)
)
const groups = computed<PartnerNodeGroup[]>(() => {
const query = searchQuery.value.trim().toLocaleLowerCase()
const byProvider = new Map<string, PartnerNodeCatalogItem[]>()
for (const node of partnerNodes.value) {
if (
query &&
![node.name, node.id, node.provider].some((value) =>
value.toLocaleLowerCase().includes(query)
)
) {
continue
}
const nodes = byProvider.get(node.provider) ?? []
nodes.push(node)
byProvider.set(node.provider, nodes)
}
return [...byProvider.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([provider, nodes]) => {
const sortedNodes = nodes.sort((left, right) =>
left.name.localeCompare(right.name)
)
return {
provider,
nodes: sortedNodes,
enabledCount: sortedNodes.filter(
(node) => draftNodes.value[node.id] === true
).length
}
})
})
function setNodeEnabled(nodeId: string, enabled: boolean): void {
draftNodes.value[nodeId] = enabled
saveError.value = false
}
function setEnforcementEnabled(enabled: boolean): void {
draftEnforcementEnabled.value = enabled
saveError.value = false
}
async function save(): Promise<void> {
if (!hasChanges.value || isSaving.value) return
isSaving.value = true
saveError.value = false
try {
const applied = await governanceStore.savePolicy({
enforcementEnabled: draftEnforcementEnabled.value,
nodes: {
...(policy.value?.nodes ?? {}),
...draftNodes.value
}
})
if (!applied) return
toastStore.add({
severity: 'success',
summary: t('workspacePanel.allowlist.saved'),
life: 2000
})
} catch {
saveError.value = true
} finally {
isSaving.value = false
}
}
function loadPolicy(): void {
void governanceStore.loadPolicy()
}
</script>

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -9,12 +10,19 @@ import WorkspacePanelContent from './WorkspacePanelContent.vue'
const mockFetchMembers = vi.fn()
const mockFetchPendingInvites = vi.fn()
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
const {
mockGovernedWorkspaceId,
mockMembers,
mockWorkspaceRole,
mockWorkspaceType
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockGovernedWorkspaceId: ref<string | null>('workspace-one'),
mockMembers: ref<WorkspaceMember[]>([]),
mockWorkspaceRole: ref<'owner' | 'member'>('owner'),
mockWorkspaceType: ref<'personal' | 'team'>('team')
}
})
@@ -41,16 +49,20 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useWorkspaceUI: () => ({
workspaceType: mockWorkspaceType,
workspaceRole: ref('owner')
workspaceRole: mockWorkspaceRole
})
}
})
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore: () => ({
governedWorkspaceId: mockGovernedWorkspaceId
})
}))
vi.mock(
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
() => ({
@@ -75,6 +87,11 @@ vi.mock(
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue',
() => ({ default: { template: '<div />' } })
)
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -94,8 +111,9 @@ function createMember(id: string): WorkspaceMember {
}
}
function renderComponent() {
function renderComponent(defaultTab?: string) {
return render(WorkspacePanelContent, {
props: { defaultTab },
global: {
plugins: [i18n],
stubs: { WorkspaceProfilePic: true }
@@ -107,6 +125,8 @@ describe('WorkspacePanelContent billing banner', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
@@ -126,6 +146,8 @@ describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
@@ -156,3 +178,56 @@ describe('WorkspacePanelContent members tab label', () => {
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})
describe('WorkspacePanelContent allowlist tab', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
it('shows the allowlist for an eligible workspace owner', () => {
renderComponent()
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeInTheDocument()
})
it('hides the allowlist from workspace members', () => {
mockWorkspaceRole.value = 'member'
renderComponent()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
})
it('hides the allowlist when governance is ineligible', () => {
mockGovernedWorkspaceId.value = null
renderComponent()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
})
it('falls back to a valid tab when allowlist access is lost', async () => {
renderComponent('allowlist')
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toHaveAttribute('aria-selected', 'true')
mockWorkspaceRole.value = 'member'
await nextTick()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.planCredits' })
).toHaveAttribute('aria-selected', 'true')
})
})

View File

@@ -39,6 +39,18 @@
: $t('workspacePanel.members.header')
}}
</TabsTrigger>
<TabsTrigger
v-if="showAllowlistTab"
value="allowlist"
:class="
cn(
tabTriggerBase,
activeTab === 'allowlist' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.allowlist') }}
</TabsTrigger>
</TabsList>
<BillingStatusBanner class="mt-4" />
@@ -49,21 +61,26 @@
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
<TabsContent v-if="showAllowlistTab" value="allowlist" class="mt-4">
<PartnerNodeAllowlistPanel />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import PartnerNodeAllowlistPanel from '@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -83,9 +100,19 @@ const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceType, workspaceRole } = useWorkspaceUI()
const { governedWorkspaceId } = storeToRefs(usePartnerNodeGovernanceStore())
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const showAllowlistTab = computed(
() => !!governedWorkspaceId.value && workspaceRole.value === 'owner'
)
const activeTab = ref(defaultTab)
watch(showAllowlistTab, (isVisible) => {
if (!isVisible && activeTab.value === 'allowlist') {
activeTab.value = defaultTab === 'allowlist' ? 'plan' : defaultTab
}
})
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1

View File

@@ -0,0 +1,411 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type * as PartnerNodePolicyApi from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import { PartnerNodePolicyApiError } from '@/platform/workspace/api/partnerNodePolicyApi'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn())
const mockUpdatePartnerNodePolicy = vi.hoisted(() => vi.fn())
const mockFlags = vi.hoisted(() => ({
teamWorkspacesEnabled: true,
partnerNodeGovernanceEnabled: true
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock(
'@/platform/workspace/api/partnerNodePolicyApi',
async (importOriginal) => {
const actual = await importOriginal<typeof PartnerNodePolicyApi>()
return {
...actual,
getPartnerNodePolicy: mockGetPartnerNodePolicy,
updatePartnerNodePolicy: mockUpdatePartnerNodePolicy
}
}
)
function nodeDef(
name: string,
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef {
return {
name,
display_name: `Display ${name}`,
category: 'partner/image/Provider',
python_module: 'comfy_api_nodes.provider',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true,
...overrides
}
}
function activateWorkspace(id: string, type: 'personal' | 'team' = 'team') {
const store = useTeamWorkspaceStore()
store.workspaces = [
{
id,
name: id,
type,
role: 'owner',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: []
}
]
store.activeWorkspaceId = id
}
async function createLoadedStore() {
const store = usePartnerNodeGovernanceStore()
await vi.waitFor(() => expect(store.status).not.toBe('loading'))
return store
}
describe('partnerNodeGovernanceStore', () => {
let store: ReturnType<typeof usePartnerNodeGovernanceStore> | undefined
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockFlags.teamWorkspacesEnabled = true
mockFlags.partnerNodeGovernanceEnabled = true
mockGetPartnerNodePolicy.mockResolvedValue(null)
mockUpdatePartnerNodePolicy.mockImplementation(
async (policy: PartnerNodePolicy) => policy
)
activateWorkspace('workspace-one')
useNodeDefStore().updateNodeDefs([
nodeDef('AllowedNode'),
nodeDef('DisabledNode'),
nodeDef('UnreviewedNode'),
nodeDef('CoreNode', {
api_node: false,
category: 'sampling',
python_module: 'nodes'
})
])
})
afterEach(() => {
delete LiteGraph.registered_node_types.LegacyGovernedNode
store?.$dispose()
store = undefined
})
it('composes partner-node catalog metadata from object info', async () => {
useNodeDefStore().updateNodeDefs([
nodeDef('PartnerNode', {
display_name: 'Partner Node',
category: 'partner/video/Acme'
}),
nodeDef('CoreNode', { api_node: false })
])
store = await createLoadedStore()
expect(store.partnerNodes).toEqual([
{ id: 'PartnerNode', name: 'Partner Node', provider: 'Acme' }
])
})
it('treats 404 as unconfigured and allows every node', async () => {
store = await createLoadedStore()
expect(store.status).toBe('unconfigured')
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('does not block nodes while enforcement is off', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('allows only explicit true rules while enforcement is on', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
expect(store.isNodeDisabled('UnreviewedNode')).toBe(true)
expect(store.isNodeDisabled('CoreNode')).toBe(false)
})
it('filters disabled nodes out of discovery', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
const nodeDefStore = useNodeDefStore()
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
'AllowedNode',
'CoreNode'
])
expect(nodeDefStore.nodeSearchService.searchNode('DisabledNode')).toEqual(
[]
)
})
it('keeps the litegraph legacy menu in sync with policy changes', async () => {
class LegacyGovernedNode extends LGraphNode {}
LiteGraph.registered_node_types.LegacyGovernedNode = LegacyGovernedNode
useNodeDefStore().addNodeDef(nodeDef('LegacyGovernedNode'))
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { LegacyGovernedNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(LegacyGovernedNode.skip_list).toBe(true)
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: false,
nodes: { LegacyGovernedNode: false }
} satisfies PartnerNodePolicy)
await store.loadPolicy()
await nextTick()
expect(LegacyGovernedNode.skip_list).toBe(false)
})
it('fails closed for a 503 from an enforcing workspace', async () => {
mockGetPartnerNodePolicy.mockRejectedValue(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
store = await createLoadedStore()
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
expect(store.isNodeDisabled('CoreNode')).toBe(false)
})
it('stays fail-closed while retrying an unavailable policy', async () => {
mockGetPartnerNodePolicy.mockRejectedValueOnce(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
store = await createLoadedStore()
let rejectRetry!: (error: Error) => void
mockGetPartnerNodePolicy.mockReturnValueOnce(
new Promise((_, reject) => {
rejectRetry = reject
})
)
const retry = store.loadPolicy()
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
rejectRetry(new Error('Network error'))
await retry
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
})
it('preserves the last enforcing policy after a generic refresh error', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
mockGetPartnerNodePolicy.mockRejectedValue(new Error('Network error'))
await store.loadPolicy()
expect(store.status).toBe('error')
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
})
it('stays inactive when partner-node governance is disabled', async () => {
mockFlags.partnerNodeGovernanceEnabled = false
store = usePartnerNodeGovernanceStore()
await nextTick()
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
expect(store.status).toBe('inactive')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('stays inactive in a personal workspace', async () => {
activateWorkspace('personal-workspace', 'personal')
store = usePartnerNodeGovernanceStore()
await nextTick()
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
expect(store.status).toBe('inactive')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('ignores a stale response after switching workspaces', async () => {
let resolveFirst!: (policy: PartnerNodePolicy) => void
mockGetPartnerNodePolicy
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve
})
)
.mockResolvedValueOnce({
enforcementEnabled: true,
nodes: { DisabledNode: true }
} satisfies PartnerNodePolicy)
store = usePartnerNodeGovernanceStore()
await vi.waitFor(() =>
expect(mockGetPartnerNodePolicy).toHaveBeenCalledTimes(1)
)
activateWorkspace('workspace-two')
await vi.waitFor(() => expect(store?.status).toBe('configured'))
resolveFirst({
enforcementEnabled: true,
nodes: { DisabledNode: false }
})
await nextTick()
expect(store.governedWorkspaceId).toBe('workspace-two')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('saves and installs the validated whole policy', async () => {
store = await createLoadedStore()
const nextPolicy = {
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
} satisfies PartnerNodePolicy
await expect(store.savePolicy(nextPolicy)).resolves.toBe(true)
expect(mockUpdatePartnerNodePolicy).toHaveBeenCalledWith(nextPolicy)
expect(store.policy).toEqual(nextPolicy)
expect(store.status).toBe('configured')
})
it('does not install a save response after switching workspaces', async () => {
store = await createLoadedStore()
let resolveSave!: (policy: PartnerNodePolicy) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((resolve) => {
resolveSave = resolve
})
)
const save = store.savePolicy({
enforcementEnabled: false,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
resolveSave({
enforcementEnabled: false,
nodes: { AllowedNode: true }
})
await expect(save).resolves.toBe(false)
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
expect(store.policy).toBeNull()
})
it('does not install a save response after switching away and back', async () => {
store = await createLoadedStore()
let resolveSave!: (policy: PartnerNodePolicy) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((resolve) => {
resolveSave = resolve
})
)
const save = store.savePolicy({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
activateWorkspace('workspace-one')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-one')
)
resolveSave({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
await expect(save).resolves.toBe(false)
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
expect(store.policy).toBeNull()
})
it('ignores a stale save rejection after switching workspaces', async () => {
store = await createLoadedStore()
let rejectSave!: (error: Error) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((_, reject) => {
rejectSave = reject
})
)
const save = store.savePolicy({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
rejectSave(new Error('Conflict'))
await expect(save).resolves.toBe(false)
expect(store.error).toBeNull()
})
it('refuses to save before governance is ready', async () => {
mockFlags.partnerNodeGovernanceEnabled = false
store = usePartnerNodeGovernanceStore()
await nextTick()
await expect(
store.savePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toThrow('Partner node governance is not ready')
expect(mockUpdatePartnerNodePolicy).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,213 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch, watchEffect } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError,
updatePartnerNodePolicy
} from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
export interface PartnerNodeCatalogItem {
id: string
name: string
provider: string
}
export type PartnerNodePolicyStatus =
| 'inactive'
| 'loading'
| 'unconfigured'
| 'configured'
| 'unavailable'
| 'error'
const DISCOVERY_FILTER_ID = 'workspace.partner-node-governance'
export const usePartnerNodeGovernanceStore = defineStore(
'partnerNodeGovernance',
() => {
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
const nodeDefStore = useNodeDefStore()
const policy = shallowRef<PartnerNodePolicy | null>(null)
const policyWorkspaceId = ref<string | null>(null)
const status = ref<PartnerNodePolicyStatus>('inactive')
const error = shallowRef<Error | null>(null)
let loadVersion = 0
let workspaceVersion = 0
const governedWorkspaceId = computed(() => {
const workspace = workspaceStore.activeWorkspace
return flags.teamWorkspacesEnabled &&
flags.partnerNodeGovernanceEnabled &&
workspace?.type === 'team'
? workspace.id
: null
})
const partnerNodes = computed<PartnerNodeCatalogItem[]>(() =>
Object.values(nodeDefStore.nodeDefsByName)
.filter((nodeDef) => nodeDef.api_node)
.map((nodeDef) => ({
id: nodeDef.name,
name: nodeDef.display_name || nodeDef.name,
provider:
nodeDef.category.split('/')[2] ||
nodeDef.category ||
nodeDef.python_module
}))
)
function isNodeDisabled(nodeType: string): boolean {
if (!nodeDefStore.nodeDefsByName[nodeType]?.api_node) return false
const workspaceId = governedWorkspaceId.value
if (!workspaceId || policyWorkspaceId.value !== workspaceId) return false
if (status.value === 'unavailable') return true
return (
policy.value?.enforcementEnabled === true &&
policy.value.nodes[nodeType] !== true
)
}
nodeDefStore.registerNodeDefFilter({
id: DISCOVERY_FILTER_ID,
name: 'Workspace partner-node governance',
predicate: (nodeDef) => !isNodeDisabled(nodeDef.name)
})
const legacyHiddenNodeTypes = new Set<string>()
watchEffect(() => {
const showDevOnly = nodeDefStore.showDevOnly
for (const nodeDef of Object.values(nodeDefStore.nodeDefsByName)) {
if (!nodeDef.api_node) continue
const nodeType = LiteGraph.registered_node_types[nodeDef.name]
if (!nodeType) continue
if (isNodeDisabled(nodeDef.name)) {
if (!nodeType.skip_list) {
nodeType.skip_list = true
legacyHiddenNodeTypes.add(nodeDef.name)
}
continue
}
if (legacyHiddenNodeTypes.delete(nodeDef.name)) {
nodeType.skip_list = nodeDef.dev_only && !showDevOnly
}
}
})
async function loadPolicy(): Promise<void> {
const workspaceId = governedWorkspaceId.value
const version = ++loadVersion
if (!workspaceId) {
policy.value = null
policyWorkspaceId.value = null
status.value = 'inactive'
error.value = null
return
}
const workspaceChanged = policyWorkspaceId.value !== workspaceId
if (workspaceChanged) {
policy.value = null
policyWorkspaceId.value = workspaceId
status.value = 'loading'
} else if (status.value !== 'unavailable') {
status.value = 'loading'
}
error.value = null
try {
const nextPolicy = await getPartnerNodePolicy()
if (
version !== loadVersion ||
governedWorkspaceId.value !== workspaceId
) {
return
}
policy.value = nextPolicy
status.value = nextPolicy ? 'configured' : 'unconfigured'
} catch (loadError) {
if (
version !== loadVersion ||
governedWorkspaceId.value !== workspaceId
) {
return
}
error.value =
loadError instanceof Error
? loadError
: new Error('Failed to load partner node policy')
if (
loadError instanceof PartnerNodePolicyApiError &&
loadError.status === 503
) {
policy.value = null
status.value = 'unavailable'
return
}
if (status.value !== 'unavailable') status.value = 'error'
}
}
async function savePolicy(nextPolicy: PartnerNodePolicy): Promise<boolean> {
const workspaceId = governedWorkspaceId.value
const version = workspaceVersion
if (!workspaceId || policyWorkspaceId.value !== workspaceId) {
throw new Error('Partner node governance is not ready')
}
let savedPolicy: PartnerNodePolicy
try {
savedPolicy = await updatePartnerNodePolicy(nextPolicy)
} catch (saveError) {
if (
workspaceVersion !== version ||
governedWorkspaceId.value !== workspaceId
) {
return false
}
throw saveError
}
if (
workspaceVersion !== version ||
governedWorkspaceId.value !== workspaceId
) {
return false
}
policy.value = savedPolicy
policyWorkspaceId.value = workspaceId
status.value = 'configured'
error.value = null
return true
}
watch(
governedWorkspaceId,
() => {
workspaceVersion++
void loadPolicy()
},
{ immediate: true, flush: 'sync' }
)
return {
policy,
status,
error,
governedWorkspaceId,
partnerNodes,
isNodeDisabled,
loadPolicy,
savePolicy
}
}
)

View File

@@ -12,7 +12,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
canRunWorkflows: true
}))
const overlayMock = vi.hoisted(() => ({
@@ -22,7 +22,7 @@ const overlayMock = vi.hoisted(() => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
canRunWorkflows: billingMock.canRunWorkflows
})
}))
@@ -77,14 +77,14 @@ const nodeErrors: Record<string, NodeError> = {
function renderControls({
hasError = false,
isActiveSubscription = true,
canRunWorkflows = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
canRunWorkflows?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
billingMock.canRunWorkflows = canRunWorkflows
const pinia = createTestingPinia({
createSpy: vi.fn,
@@ -120,7 +120,7 @@ function renderControls({
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
billingMock.canRunWorkflows = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
@@ -187,7 +187,7 @@ describe('LinearControls', () => {
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
canRunWorkflows: false,
mobile
})

View File

@@ -11,6 +11,7 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -28,7 +29,7 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
const { isActiveSubscription } = useBillingContext()
const { canRunWorkflows } = useBillingContext()
const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
@@ -54,7 +55,7 @@ const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(canRunWorkflows) &&
toValue(overlayMessage).trim().length > 0
)
@@ -152,10 +153,7 @@ function handleDragDrop() {
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
/>
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<div v-else class="mt-4 flex">
<PartnerNodesList mobile />
<Popover side="top" @open-auto-focus.prevent>
@@ -210,10 +208,7 @@ function handleDragDrop() {
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
/>
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<Button
v-else
variant="primary"
@@ -229,6 +224,7 @@ function handleDragDrop() {
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<FreeTierQuota />
</section>
</div>
</div>

View File

@@ -4,33 +4,17 @@ import {
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
defineProps<{ mobile?: boolean }>()
const { isCreditsBadge } = usePriceBadge()
const creditsBadges = useCreditsBadgesInGraph()
const { t } = useI18n()
const creditsBadges = computed(() =>
mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
)
</script>
<template>
<Popover v-if="mobile && creditsBadges.length" side="top">

View File

@@ -224,10 +224,9 @@ function resolveValues(values: unknown): string[] {
}
const modelValue = defineModel<string | undefined>({
default(modelProps: Record<string, unknown>) {
default(modelProps: Props) {
try {
const modelWidget = modelProps.widget as Props['widget'] | undefined
const values = modelWidget?.options?.values
const values = modelProps.widget.options?.values
const resolved = typeof values === 'function' ? values() : values
const firstValue = Array.isArray(resolved)
? resolved.find((value) => value !== null && value !== undefined)

View File

@@ -41,9 +41,8 @@ provide(
)
const modelValue = defineModel<string | undefined>({
default(modelProps: Record<string, unknown>) {
const modelWidget = modelProps.widget as Props['widget'] | undefined
const values = modelWidget?.options?.values
default(props: Props) {
const values = props.widget.options?.values
return (Array.isArray(values) ? values[0] : undefined) ?? ''
}
})

View File

@@ -24,6 +24,7 @@ import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -1813,6 +1814,7 @@ export class ComfyApp {
isPartialExecution
})
}
useFreeTierQuota().trackRun()
this.canvas.draw(true, true)
await this.ui.queue.update()
}

View File

@@ -124,7 +124,7 @@ const findElementsInTag = (
end: number,
id: Uint8Array
): EbmlElementRange | null => {
for (let pos = start; pos < end - 1;) {
for (let pos = start; pos < end - 1; ) {
if (matchesId(data, pos, id)) {
const size = readVint(data, pos + 2)
if (size && size.value > 0) {
@@ -307,7 +307,7 @@ const ebmlToString = (
const parseMetadata = (data: Uint8Array): ComfyMetadata => {
const meta: ComfyMetadata = {}
for (let pos = 0; pos < data.length - 2;) {
for (let pos = 0; pos < data.length - 2; ) {
const tagInfo = findNextTag(data, pos)
if (!tagInfo) {
pos++

View File

@@ -306,9 +306,13 @@ export function useNodeFactory() {
// Create node based on type and configuration
switch (type) {
case 'basic':
return {/* basic node implementation */}
return {
/* basic node implementation */
}
case 'complex':
return {/* complex node implementation */}
return {
/* complex node implementation */
}
default:
throw new Error(`Unknown node type: ${type}`)
}
@@ -343,7 +347,9 @@ Services in ComfyUI can be tested effectively using different approaches dependi
```typescript
// Manual instantiation required
const mockData = [/* test data */]
const mockData = [
/* test data */
]
const service = new NodeSearchService(mockData)
```

View File

@@ -147,7 +147,9 @@ describe('useAuthStore', () => {
let authStateCallback: (user: User | null) => void
let idTokenCallback: (user: User | null) => void
const mockAuth: MockAuth = {/* mock Auth object */}
const mockAuth: MockAuth = {
/* mock Auth object */
}
const mockUser: MockUser = {
uid: 'test-user-id',

View File

@@ -1,6 +1,7 @@
import axios from 'axios'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -10,7 +11,7 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
describe('useNodeDefStore', () => {
@@ -21,6 +22,10 @@ describe('useNodeDefStore', () => {
store = useNodeDefStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef => ({
@@ -286,6 +291,30 @@ describe('useNodeDefStore', () => {
})
})
describe('node frequency visibility', () => {
it('excludes node definitions hidden by registered filters', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({
data: { HiddenNode: 2, VisibleNode: 1 }
})
store.updateNodeDefs([
createMockNodeDef({ name: 'HiddenNode' }),
createMockNodeDef({ name: 'VisibleNode' })
])
store.registerNodeDefFilter({
id: 'test.hide-node',
name: 'Hide node',
predicate: (nodeDef) => nodeDef.name !== 'HiddenNode'
})
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual(
['VisibleNode']
)
})
})
describe('allNodeDefsByName', () => {
it('should include all node defs by name', () => {
const node1 = createMockNodeDef({ name: 'Node1' })

View File

@@ -562,9 +562,15 @@ export const useNodeFrequencyStore = defineStore('nodeFrequency', () => {
const nodeDefStore = useNodeDefStore()
const topNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
const visibleNodeNames = new Set(
nodeDefStore.visibleNodeDefs.map((nodeDef) => nodeDef.name)
)
return nodeNamesByFrequency.value
.map((nodeName: string) => nodeDefStore.nodeDefsByName[nodeName])
.filter((nodeDef: ComfyNodeDefImpl) => nodeDef !== undefined)
.filter(
(nodeDef): nodeDef is ComfyNodeDefImpl =>
nodeDef !== undefined && visibleNodeNames.has(nodeDef.name)
)
.slice(0, topNodeDefLimit.value)
})

View File

@@ -55,6 +55,7 @@ export function useBillingContext(): BillingContext {
isLoading: ref(false),
error: ref<string | null>(null),
isActiveSubscription: computed(() => state.value.isActiveSubscription),
canRunWorkflows: computed(() => state.value.isActiveSubscription),
isFreeTier: computed(() => false),
isLegacyTeamPlan: computed(() => false),
isTeamPlan: computed(() => state.value.isTeamPlan),

View File

@@ -132,9 +132,9 @@ export function fixBadLinks(
// We can set to null (delete), so undefined means we haven't set it at all.
if (patchedNode['inputs']![slot] !== undefined) {
logger.log(
` > Already set ${node.id}.inputs[${slot}] to ${
patchedNode['inputs']![slot]!
} Skipping.`
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode[
'inputs'
]![slot]!} Skipping.`
)
return false
}