Compare commits

...

4 Commits

Author SHA1 Message Date
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
28 changed files with 881 additions and 114 deletions

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

@@ -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,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

@@ -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

@@ -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

@@ -3587,6 +3587,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

@@ -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,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError
} 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'
})
})
})

View File

@@ -0,0 +1,39 @@
import { z } from 'zod'
import { api } from '@/scripts/api'
const partnerNodePolicyResponseSchema = z.object({
enforcement_enabled: z.boolean(),
nodes: z.record(z.string(), z.boolean())
})
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'
}
}
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
const response = await api.fetchApi('/workspace/partner-node-policy', {
cache: 'no-store'
})
if (response.status === 404) return null
if (!response.ok) {
throw new PartnerNodePolicyApiError(response.status, response.statusText)
}
const data = partnerNodePolicyResponseSchema.parse(await response.json())
return {
enforcementEnabled: data.enforcement_enabled,
nodes: data.nodes
}
}

View File

@@ -0,0 +1,257 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 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
}
}
)
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)
activateWorkspace('workspace-one')
useNodeDefStore().updateNodeDefs([
nodeDef('AllowedNode'),
nodeDef('DisabledNode'),
nodeDef('UnreviewedNode'),
nodeDef('CoreNode', {
api_node: false,
category: 'sampling',
python_module: 'nodes'
})
])
})
afterEach(() => {
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('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)
})
})

View File

@@ -0,0 +1,139 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError
} 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'
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
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
)
}
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'
}
}
watch(governedWorkspaceId, () => void loadPolicy(), { immediate: true })
return {
policy,
status,
error,
governedWorkspaceId,
partnerNodes,
isNodeDisabled,
loadPolicy
}
}
)

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

@@ -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

@@ -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),