Compare commits

..

4 Commits

Author SHA1 Message Date
huang47
81332a3dca test: probe informational Codecov status 2026-07-17 15:29:37 -07:00
huang47
d4b1ced906 ci: make Codecov patch coverage informational 2026-07-17 13:27:56 -07:00
Maanil Verma
d791351a1c feat(subscription): unify credit gold across indicators and subscribe buttons (#13763)
## Summary

Credit indicators and subscribe CTAs now share one gold, so the credit
balance, the amounts in pricing and checkout, and the buttons that
upgrade or top up all read as a single system. The subscribe buttons
also drop the old purple gradient for a solid gold that is actually
legible.

## Changes

**What**

- Recolor the credit accent to a single gold value and point every
credit surface at that one token, replacing the scattered amber and gold
used before.
- Repaint the subscribe buttons: they were a multi layer purple gradient
with faint white text (nearly unreadable), and are now a solid gold fill
with dark text. Hover dims slightly instead of shifting the gradient.
- Rename the button `subscribe` variant (it used to be called
`gradient`, which stopped being accurate once it became a flat fill) and
delete the now unused gradient token so there is no dead styling left
behind.
- Swap the credit cost glyphs across pricing tables, subscription
success and preview screens, the free tier quota, the partner node
popover, and both user popovers so they match the recolored token rather
than raw amber.
- Built on top of #13712 

**Breaking**

None. The subscribe buttons keep the same public API (the shared
`SubscribeButton` still exposes a `buttonVariant` prop, now typed
`'default' | 'subscribe'`), and no behavior, routing, or telemetry
changes.

## Testing

This is a token and styling change with no logic, so the meaningful
verification is visual plus the standard gates, not new unit tests (a
test asserting a hex value would just be a change detector).

<img width="507" height="557" alt="image"
src="https://github.com/user-attachments/assets/fed73d12-e3cc-46a6-aae2-db37f24d9bbb"
/>
2026-07-18 00:37:26 +05:30
Mobeen Abdullah
922be31b74 feat(website): rename Comfy Hub to Comfy Workflows in user-facing copy (#13718)
## Summary

Marketing-site half of the **Comfy Hub → Comfy Workflows** rename
([GTM-271](https://linear.app/comfyorg/issue/GTM-271/comfy-workflows-rename-site-docs-copylogo-sweep));
the hub-side sweep is Comfy-Org/workflow_templates#1015. Silent copy
change, links keep pointing at comfy.org/workflows.

## Changes

- **What**: renames the six user-facing "Comfy Hub" mentions to "Comfy
Workflows": the Community nav item, the homepage/models showcase card
title, the models-page "COMFY HUB" eyebrow, the "Browse Comfy Hub" link
(en + zh-CN), and the two supported-models sentences (body copy + FAQ
JSON-LD text).
- Internal identifiers unchanged per the rename PRD non-goals (the
`nav.comfyHub` i18n key stays).

## Review Focus

- The supported-models copy also feeds the FAQPage JSON-LD;
`validate:jsonld` passes on all 503 pages.
- zh-CN strings keep the brand in Latin script ("Comfy Workflows"),
matching how "Comfy Hub" was handled.

## Verification

- typecheck 0 errors · unit 199/199 · oxfmt clean · build 500 pages ·
validate:jsonld 503 pages · built HTML contains zero remaining "Comfy
Hub".

## Screenshots (if applicable)

Attached below (Community nav dropdown, models-page showcase section).
2026-07-17 18:19:41 +00:00
28 changed files with 43 additions and 614 deletions

View File

@@ -83,8 +83,8 @@ const translations = {
'如果您是 ComfyUI 新手,可以从 App 模式开始——这是工作流的简化视图。您随时可以切换回节点图视图以深入了解。'
},
'showcase.feature3.title': {
en: 'Community Workflows on Comfy Hub',
'zh-CN': 'Comfy Hub 上的社区工作流'
en: 'Community Workflows on Comfy Workflows',
'zh-CN': 'Comfy Workflows 上的社区工作流'
},
'showcase.feature3.description': {
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template and customize it to your needs.',
@@ -2272,7 +2272,7 @@ const translations = {
en: 'Comfy Enterprise',
'zh-CN': 'Comfy 企业版'
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.comfyHub': { en: 'Comfy Workflows', 'zh-CN': 'Comfy Workflows' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
@@ -4302,8 +4302,8 @@ const translations = {
'zh-CN': '{count} 个工作流'
},
'models.list.contact.label': {
en: 'COMFY HUB',
'zh-CN': 'COMFY HUB'
en: 'COMFY WORKFLOWS',
'zh-CN': 'COMFY WORKFLOWS'
},
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
'models.showcase.heading': {
@@ -4339,9 +4339,9 @@ const translations = {
'zh-CN': 'Wan 2.2\n文字转视频'
},
'models.list.contact.heading': {
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Workflows</a> for the newest workflows.',
'zh-CN':
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Workflows</a> 查看最新工作流。'
},
// Payment status pages

View File

@@ -44,7 +44,7 @@ const dirDescriptions: Record<string, string> = {
}
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Workflows, ready to load and customize.`
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
@@ -87,7 +87,7 @@ const faqPage: JsonLdNode = {
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Workflows. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
},
{

View File

@@ -1,3 +1,9 @@
coverage:
status:
patch:
default:
informational: true
comment:
layout: 'header, diff, flags, files'
behavior: default

View File

@@ -49,7 +49,7 @@
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-credit: #fabc25;
--color-credit: #ddbd31;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
@@ -140,26 +140,6 @@
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--subscription-button-gradient:
linear-gradient(
315deg,
rgb(105 230 255 / 0.15) 0%,
rgb(99 73 233 / 0.5) 100%
),
radial-gradient(
70.71% 70.71% at 50% 50%,
rgb(62 99 222 / 0.15) 0.01%,
rgb(66 0 123 / 0.5) 100%
),
linear-gradient(
92deg,
#d000ff 0.38%,
#b009fe 37.07%,
#3e1ffc 65.17%,
#009dff 103.86%
),
linear-gradient(var(--color-button-surface, #2d2e32));
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
--code-bg-color: rgb(96 165 250 / 0.2);

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--component] text-sm text-credit" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
@@ -48,7 +48,7 @@
</Button>
<Button
v-if="isCloud && isFreeTier"
variant="gradient"
variant="subscribe"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
@@ -72,7 +72,7 @@
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
button-variant="gradient"
button-variant="subscribe"
@subscribed="handleSubscribed"
/>
</div>

View File

@@ -2,7 +2,7 @@
<Button
v-if="isCloud && isFreeTier"
class="mr-2 shrink-0 whitespace-nowrap"
variant="gradient"
variant="subscribe"
size="sm"
data-testid="topbar-subscribe-button"
@click="handleClick"

View File

@@ -24,8 +24,8 @@ export const buttonVariants = cva({
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
subscribe:
'border-transparent bg-credit text-charcoal-800 hover:opacity-80'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -58,7 +58,7 @@ const variants = [
'base',
'tertiary',
'overlay-white',
'gradient'
'subscribe'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -132,7 +132,7 @@ export function useChart(
const chartInstance = ref<Chart | null>(null)
function createChart() {
if (!canvasRef.value) return
if (canvasRef.value === null) return
chartInstance.value?.destroy()

View File

@@ -182,30 +182,6 @@ 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)
})
it('defaults to false when the remote flag is unset', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.partnerNodeGovernanceEnabled).toBe(false)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -24,7 +24,6 @@ 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',
@@ -135,13 +134,6 @@ 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,

View File

@@ -147,7 +147,7 @@
<div v-if="showActionButton" class="flex flex-col gap-3">
<Button
v-if="isFreeTier"
variant="gradient"
variant="subscribe"
size="lg"
class="w-full font-normal"
@click="handleUpgradeToAddCredits"

View File

@@ -43,7 +43,7 @@ const label = computed(() =>
v-if="hasInvalidNodes"
class="flex w-full items-center justify-center gap-2"
>
<i class="icon-[comfy--credits] bg-amber-400" />
<i class="icon-[comfy--credits] bg-credit" />
{{ t('actionbar.freeTierPartner') }}
</div>
<div v-else class="flex w-full items-center justify-between">

View File

@@ -123,7 +123,7 @@
</span>
<div class="flex flex-row items-center gap-1">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
aria-hidden="true"
/>
<span

View File

@@ -2,7 +2,7 @@
<Button
:size
:disabled="disabled"
:variant="buttonVariant === 'gradient' ? 'gradient' : 'primary'"
:variant="buttonVariant === 'subscribe' ? 'subscribe' : 'primary'"
:class="cn('font-bold', fluid && 'w-full')"
@click="handleSubscribe"
>
@@ -28,7 +28,7 @@ const {
} = defineProps<{
label?: string
size?: 'sm' | 'lg'
buttonVariant?: 'default' | 'gradient'
buttonVariant?: 'default' | 'subscribe'
fluid?: boolean
disabled?: boolean
}>()

View File

@@ -5,7 +5,7 @@
showDelay: 600
}"
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
variant="gradient"
variant="subscribe"
size="unset"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"

View File

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

View File

@@ -1,70 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -59,7 +59,7 @@
<!-- Credits Section -->
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--component] text-sm text-credit" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
@@ -82,7 +82,7 @@
<!-- Upgrade to add credits (free tier) -->
<Button
v-if="isActiveSubscription && permissions.canTopUp && isFreeTier"
variant="gradient"
variant="subscribe"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
@@ -110,7 +110,7 @@
: $t('workspaceSwitcher.subscribe')
"
size="sm"
button-variant="gradient"
button-variant="subscribe"
/>
<Button
v-if="

View File

@@ -129,7 +129,7 @@
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--component] text-sm text-credit" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
>

View File

@@ -38,7 +38,7 @@
{{ $t(creditsRefillLabelKey) }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
<span class="font-bold text-base-foreground">
{{ refillCredits }}
</span>

View File

@@ -28,7 +28,7 @@
</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
</div>
</div>

View File

@@ -66,7 +66,7 @@
<div class="flex items-center justify-between">
<span class="text-base-foreground">{{ refillLabel }}</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
<span class="font-bold text-base-foreground">{{
refillCredits
}}</span>
@@ -89,7 +89,7 @@
{{ $t('subscription.preview.creditsRefillMonthlyTo') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
<span class="font-bold text-base-foreground">{{
monthlyRefillCredits
}}</span>

View File

@@ -166,7 +166,7 @@
<div class="mt-auto flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
aria-hidden="true"
/>
<span
@@ -244,7 +244,7 @@
<div class="flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
aria-hidden="true"
/>
<span

View File

@@ -1,276 +0,0 @@
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('fails open during initial loading and a generic failure', async () => {
let rejectLoad!: (error: Error) => void
mockGetPartnerNodePolicy.mockReturnValue(
new Promise((_, reject) => {
rejectLoad = reject
})
)
store = usePartnerNodeGovernanceStore()
expect(store.status).toBe('loading')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
rejectLoad(new Error('Network error'))
await vi.waitFor(() => expect(store?.status).toBe('error'))
expect(store.isNodeDisabled('DisabledNode')).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

@@ -1,139 +0,0 @@
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

@@ -7,7 +7,7 @@ defineProps<{ title: string; price: string }>()
<span
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
>
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
<i class="mr-1 icon-[lucide--component] h-4 bg-credit" />
{{ price }}
</span>
</div>

View File

@@ -20,7 +20,7 @@ const { t } = useI18n()
<Popover v-if="mobile && creditsBadges.length" side="top">
<template #button>
<Button class="mr-2 size-10">
<i class="icon-[comfy--credits] bg-amber-400" />
<i class="icon-[comfy--credits] bg-credit" />
</Button>
</template>
<section
@@ -51,7 +51,7 @@ const { t } = useI18n()
<div class="mb-1 w-full border-b border-border-subtle" />
<CollapsibleTrigger as-child>
<Button variant="textonly" class="w-full text-sm">
<i class="icon-[comfy--credits] size-4 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 bg-credit" />
{{ t('linearMode.hasCreditCost') }}
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
<i v-else class="ml-auto icon-[lucide--chevron-down]" />