Compare commits

..

7 Commits

Author SHA1 Message Date
Dante
1f759a758c feat(workspace): creator-only canManageSubscriptionLifecycle permission (FE-770) (#12829)
## What

Adds a creator-only `canManageSubscriptionLifecycle` permission (cancel
/ reactivate / downgrade) to `useWorkspaceUI`:

- Any workspace **owner** keeps `canManageSubscription` (manage payment
/ top-up / change-commit).
- Only the **original owner (creator)** also gets
`canManageSubscriptionLifecycle`.
- **Personal** workspaces (single-member) always get it; **members**
never do.

Driven by an `is_creator` flag on the workspace from `/api/workspaces`,
plumbed through the shared type and the auth/session Zod schema.

## Safe to merge ahead of BE / no regression

- **Pure infra — nothing reads the permission yet.** Consumers land
separately: #12786 (cancel/reactivate), #12789 (downgrade). No code on
`main` references `canManageSubscriptionLifecycle` or `is_creator`.
- **Additive & fails closed.** `is_creator` is optional; when absent the
permission is `false`. Existing permission values are unchanged, so
existing consumers (`SubscribeToRun`,
`SubscriptionPanelContentWorkspace`, `CurrentUserPopoverWorkspace`, …)
behave identically.

## BE contract (confirmed with Hunter, 2026-06-17)

- `is_creator` = current-user-relative boolean on `/api/workspaces`; the
creator is tracked explicitly (not by creation date).
- Intentionally **temporary** — removed once member-removal
auto-provisions a personal workspace.
- The FE-770 members-panel creator-**row** lock additionally needs the
creator **id** (`created_by_user_id`) — separate, still-open ask.

## Follow-up (separate PR, once BE ships)

`is_creator` + `WorkspaceWithRoleSchema` are hand-rolled only because
the cloud ingest OpenAPI doesn't expose the field yet.
`@comfyorg/ingest-types` already generates `WorkspaceWithRole` +
`zWorkspaceWithRole`. Once BE adds `is_creator` to
`services/ingest/openapi.yaml`, regenerate and swap the hand-rolled
interface + schema for the generated ones (TODOs marked in
`workspaceApi.ts` / `workspaceAuthStore.ts`).
2026-06-19 23:41:36 +00:00
Matt Miller
44557fd138 fix(asset-card): read image dimensions from typed metadata field (#12328)
## ELI-5

The asset library shows each image's resolution (e.g. "1920×1080") on
its card. It used to get that by measuring the picture the browser
actually drew — fine locally, but on cloud the card draws a small
**downscaled thumbnail**, so it measured the thumbnail and showed the
wrong size (like "512×288") instead of the real one.

Now the card prefers the **true dimensions the server records** for each
image. When the server hasn't recorded them yet (an old image not
backfilled, or something unmeasurable like an SVG), it only falls back
to measuring the drawn image when that image is the original — never a
downscaled thumbnail. If all it can measure is a thumbnail, it shows
**no size rather than a wrong one**: a blank beats a confidently wrong
number.

## Summary

Asset cards render image dimensions from the asset response's typed
`metadata` field (`asset.metadata.width` / `asset.metadata.height`) when
available, falling back to the locally-measured `naturalWidth` /
`naturalHeight` of the rendered `<img>` — but only when the rendered
image was the original, not a downscaled thumbnail.

## Why

`MediaAssetCard.vue` previously read dimensions from the rendered
`<img>`'s `naturalWidth` / `naturalHeight` — correct only when the
runtime serves the original file. Runtimes that serve a downscaled
preview return preview-sized dimensions, so the label could surface the
preview's size rather than the source asset's. Reading the typed
`metadata` field when present fixes that. And when metadata is absent,
the natural-size fallback is now **suppressed if a downscaled thumbnail
was rendered** (`asset.thumbnail_url` present), so the card shows no
dimensions rather than a misleading thumbnail size.

## What changes

- `assetMetadataUtils.ts`: new pure
`resolveDisplayImageDimensions(asset, renderedNaturalSize)` — prefers
server `metadata.{width,height}`, falls back to the rendered natural
size only when no thumbnail was rendered, otherwise returns `undefined`
(no label). Adds an exported `ImageDimensions` type.
- `MediaAssetCard.vue`: `displayImageDimensions` delegates to the
helper; `metaInfo` reads from it and does not branch on runtime
identity.
- `assetMetadataUtils.test.ts`: unit tests for
`resolveDisplayImageDimensions` across every branch (metadata present,
metadata-wins-over-thumbnail, no-thumbnail fallback, thumbnail-guard
suppression, invalid shape, both-absent, undefined asset).

## Compatibility

- **metadata has numeric `width`/`height`** → card displays the typed
(true) values.
- **metadata absent, no thumbnail rendered (original served)** →
unchanged: displays the locally-measured dimensions.
- **metadata absent, downscaled thumbnail rendered** → displays **no**
dimensions. This is the one intentional behavior change: previously the
card showed the thumbnail's size (a wrong value); now it shows a blank
until the real dimensions are available (e.g. after the cloud dimension
backfill), and stays blank for permanently-unmeasurable assets
(SVG/corrupt). Blank instead of wrong.

## Test plan

- [x] `pnpm test:unit` — `resolveDisplayImageDimensions` covered across
all branches (70 tests pass)
- [x] `oxfmt --check` + `oxlint --type-aware` clean on touched files
- [ ] `pnpm typecheck` (vue-tsc) — via CI
- [ ] Manual: asset library — image cards show real resolution on cloud;
un-backfilled / SVG cards show no size label (not a thumbnail size);
non-image cards (video / audio / 3D) still render their size label

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12328-refactor-asset-card-read-image-dimensions-from-typed-metadata-field-3656d73d3650811bb0cff00b6bc2d2e8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Matt Miller <mattmillerai@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-19 23:30:16 +00:00
Christian Byrne
90210292d7 fix: add explicit return types for composables to fix TS declaration errors (#13034)
## Summary
- Fix TS2742 and TS4060 errors during .d.ts generation in the types
build
- Add explicit interface definitions and return type annotations for
composables

## Changes
- `confirmDialog.ts`: explicit `DialogInstance` return type
- `useImageCrop.ts`: export `ResizeHandle` interface
- `useAbsolutePosition.ts`: `UseAbsolutePositionReturn` interface
- `useDomClipping.ts`: `UseDomClippingReturn` interface
- `useNodePreviewAndDrag.ts`: `UseNodePreviewAndDragReturn` interface
- `i18n.ts`: explicit type annotations for exported functions

## Test plan
- [ ] Types build succeeds: `pnpm build:types`
- [ ] No new lint errors: `pnpm lint`
- [ ] Type check passes: `pnpm typecheck`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-19 16:34:22 -07:00
pythongosssss
c8b5589768 feat: remove subscript prompt dialog on load (#13012)
## Summary

The first thing a user sees when they log in is the paywall prompt which
is a poor UX. This removes the dialog that pops up as the run buttons
are already gated to prompt to subscribe.

## Changes

- **What**: 
- Remove extension & invocation
2026-06-19 21:31:17 +00:00
Christian Byrne
b4b95980da fix: publish core/* releases immediately to create tags (#12995)
## Summary
- Fix release pipeline where core/* releases hang indefinitely waiting
for tags

## Problem
Draft releases don't create git tags, but `publish-pypi` workflow waits
for the tag to exist. For `core/*` branches, releases were always
created as drafts, causing the pipeline to wait forever.

## Solution
Only use draft releases for prereleases (alpha/beta/rc). Publish all
stable releases (main and core/*) immediately so tags are created.

## Test plan
- [x] Verify workflow change logic is correct
- [ ] Test on next core/1.45 release

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-19 14:28:49 -07:00
Deep Mehta
8c0af36c4a fix: re-disable ultralytics asset-picker registration (#13020)
## Summary

Re-comment the `ultralytics/bbox` and `ultralytics/segm` entries in
`MODEL_NODE_MAPPINGS` so `UltralyticsDetectorProvider` falls back to the
static combo from `/api/object_info`, restoring the working post-#12075
behavior. Companion to
[Comfy-Org/cloud#4395](https://github.com/Comfy-Org/cloud/pull/4395)
(ONNX dummy-placeholder fix).

## Why

PR #12151 (2026-05-12) re-added these entries before BE-689 (the
cloud-side asset-ingestion fix) landed, reintroducing both halves of the
regression that #12075 originally cited:

1. **Tag lookup mismatch.** Cloud stores model tags as combined values
like `ultralytics/bbox`, but the asset query splits them into `(models,
ultralytics)` with exact-match filtering, so the picker returns no
results.
2. **Submitted value mismatch.** Picker returns basenames like
`face_yolov8m.pt`, but the ingest service validates against the
subdirectory-prefixed form (`bbox/face_yolov8m.pt`) that the static
combo path produces. The combo rejects the basename with `Value not in
list` (the exact error users hit in BE-1116).

Until BE-689 lands both halves of the cloud fix, the asset-picker
registration cannot ship without breaking the node.

## Changes

- **What**: comment out the two ultralytics entries in
`src/platform/assets/mappings/modelNodeMappings.ts` with an inline note
pointing at BE-689 and PR #12075 / #12151 for context.
- **Breaking**: none. The node returns to the same static-combo behavior
it had between #12075 and #12151.
- **Dependencies**: none.

## Review Focus

- The existing assertion in `src/stores/modelToNodeStore.test.ts:278`
already expects `ultralytics`, `ultralytics/bbox`, and
`ultralytics/segm` to NOT resolve to a default provider — that test was
added with #12075 and was left in place when #12151 re-added the
mappings. After this change the mappings file is consistent with the
test again. 55/55 in `modelToNodeStore.test.ts` pass locally.
- This is a targeted revert of the ultralytics half of #12151. The other
entries that PR added (`background_removal`, `frame_interpolation`,
`film`) are unaffected.

## Companion PRs

- Comfy-Org/cloud#4395 — fixes the `ONNXDetectorProvider` half of the
FaceDetailer-broken report (BE-1116) by repairing the dummy-placeholder
mechanism so the static combo carries the right model list. ONNX uses
the static-combo path (no `MODEL_NODE_MAPPINGS` entry), so the
cloud-side fix is sufficient there.

<!-- Fixes BE-689 -->
2026-06-19 20:59:56 +00:00
jaeone94
78a8d6f8fc test: stabilize node help locale e2e (#12998)
## Summary

Stabilizes the locale-specific Node Help E2E by setting the locale
through the existing Playwright settings fixture before app bootstrap
instead of racing a workflow reload pulse.

## Changes

- **What**: Removed the brittle in-page locale mutation helper and uses
`test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })` for the
locale-specific documentation case.
- **What**: Keeps the Japanese and English doc routes local to the test,
then verifies the Japanese help content after loading the default
workflow.
- **Dependencies**: None.

## Review Focus

Please focus on whether the E2E now waits on the correct setup boundary.
The previous helper watched `ChangeTracker.isLoadingGraph` after
changing `Comfy.Locale`; once unrelated workflow-load work became
faster, that loading pulse could complete before the helper observed it.
Pre-boot `initialSettings` avoids that timing dependency and uses
existing test infrastructure.

Verification:

- `pnpm exec oxfmt --check browser_tests/tests/nodeHelp.spec.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm exec eslint browser_tests/tests/nodeHelp.spec.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5176 pnpm
exec playwright test browser_tests/tests/nodeHelp.spec.ts --grep "Should
handle locale-specific documentation" --project=chromium
--repeat-each=10`

## Screenshots (if applicable)

N/A
2026-06-19 19:50:04 +00:00
32 changed files with 1422 additions and 389 deletions

View File

@@ -92,9 +92,7 @@ jobs:
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true

View File

@@ -4,7 +4,6 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId('properties-panel')
}
async function setLocaleAndWaitForWorkflowReload(
comfyPage: ComfyPage,
locale: string
) {
await comfyPage.page.evaluate(async (targetLocale) => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) {
throw new Error('No active workflow while waiting for locale reload')
}
const changeTracker = workflow.changeTracker.constructor as unknown as {
isLoadingGraph: boolean
}
let sawLoading = false
const waitForReload = new Promise<void>((resolve, reject) => {
const timeoutAt = performance.now() + 5000
const tick = () => {
if (changeTracker.isLoadingGraph) {
sawLoading = true
}
if (sawLoading && !changeTracker.isLoadingGraph) {
resolve()
return
}
if (performance.now() > timeoutAt) {
reject(
new Error(
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
)
)
return
}
requestAnimationFrame(tick)
}
tick()
})
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
await waitForReload
}, locale)
}
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -398,34 +347,33 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
test.describe('Locale-specific documentation', () => {
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
これは日本語のドキュメントです。
`
})
})
})
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
This is English documentation.
`
})
})
})
// Set locale to Japanese
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
try {
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
@@ -434,9 +382,7 @@ This is English documentation.
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
} finally {
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
}
})
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {

View File

@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
@@ -151,11 +151,21 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const url = new URL(response.url())
return url.pathname.endsWith('/object_info') && response.ok()
})
const modelFoldersResponse = comfyPage.page.waitForResponse(
(response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/experiment/models') && response.ok()
}
)
const refreshButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelRefresh
)
await Promise.all([objectInfoResponse, refreshButton.click()])
await Promise.all([
objectInfoResponse,
modelFoldersResponse,
refreshButton.click()
])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()

View File

@@ -1,6 +1,7 @@
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
@@ -11,7 +12,9 @@ interface ConfirmDialogOptions {
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
export function showConfirmDialog(
options: ConfirmDialogOptions = {}
): DialogInstance {
const dialogStore = useDialogStore()
const { key, headerProps, props, footerProps } = options
return dialogStore.showDialog({

View File

@@ -1,4 +1,4 @@
import type { CSSProperties } from 'vue'
import type { CSSProperties, Ref } from 'vue'
import { ref, watch } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
@@ -15,7 +15,14 @@ export interface PositionConfig {
scale?: number
}
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
interface UseAbsolutePositionReturn {
style: Ref<CSSProperties>
updatePosition: (config: PositionConfig) => void
}
export function useAbsolutePosition(
options: { useTransform?: boolean } = {}
): UseAbsolutePositionReturn {
const { useTransform = false } = options
const canvasStore = useCanvasStore()

View File

@@ -1,4 +1,4 @@
import type { CSSProperties } from 'vue'
import type { CSSProperties, Ref } from 'vue'
import { ref } from 'vue'
interface Rect {
@@ -28,7 +28,26 @@ interface ClippingOptions {
margin?: number
}
export const useDomClipping = (options: ClippingOptions = {}) => {
interface UseDomClippingReturn {
style: Ref<CSSProperties>
updateClipPath: (
element: HTMLElement,
canvasElement: HTMLCanvasElement,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
) => void
}
export function useDomClipping(
options: ClippingOptions = {}
): UseDomClippingReturn {
const style = ref<CSSProperties>({})
const { margin = 4 } = options

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, Ref } from 'vue'
import type { ComputedRef, CSSProperties, Ref } from 'vue'
import { computed, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
@@ -8,10 +8,23 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const PREVIEW_WIDTH = 200
const PREVIEW_MARGIN = 16
interface UseNodePreviewAndDragReturn {
previewRef: Ref<HTMLElement | null>
isHovered: Ref<boolean>
isDragging: Ref<boolean>
showPreview: ComputedRef<boolean>
nodePreviewStyle: Ref<CSSProperties>
sidebarLocation: ComputedRef<'left' | 'right'>
handleMouseEnter: (e: MouseEvent) => void
handleMouseLeave: () => void
handleDragStart: (e: DragEvent) => void
handleDragEnd: (e: DragEvent) => void
}
export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
panelRef?: Ref<HTMLElement | null>
) {
): UseNodePreviewAndDragReturn {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const settingStore = useSettingStore()
const sidebarLocation = computed<'left' | 'right'>(() =>

View File

@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
type ResizeDirection =
export type ResizeDirection =
| 'top'
| 'bottom'
| 'left'
@@ -17,6 +17,17 @@ type ResizeDirection =
| 'sw'
| 'se'
export interface ResizeHandle {
direction: ResizeDirection
class: string
style: {
left: string
top: string
width?: string
height?: string
}
}
const HANDLE_SIZE = 8
const CORNER_SIZE = 10
/** Minimum crop width/height in source image pixel space. */
@@ -264,17 +275,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
height: `${cropHeight.value * scaleFactor.value}px`
}))
interface ResizeHandle {
direction: ResizeDirection
class: string
style: {
left: string
top: string
width?: string
height?: string
}
}
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
const allResizeHandles = computed<ResizeHandle[]>(() => {

View File

@@ -1,26 +0,0 @@
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExtensionService } from '@/services/extensionService'
/**
* Cloud-only extension that enforces active subscription requirement
*/
useExtensionService().registerExtension({
name: 'Comfy.Cloud.Subscription',
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { requireActiveSubscription } = useBillingContext()
const checkSubscriptionStatus = () => {
if (!isLoggedIn.value) return
void requireActiveSubscription()
}
watch(() => isLoggedIn.value, checkSubscriptionStatus, {
immediate: true
})
}
})

View File

@@ -36,10 +36,6 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
if (window.__CONFIG__?.subscription_required) {
await import('./cloudSubscription')
}
}
// Feedback button for cloud and nightly builds

View File

@@ -154,8 +154,10 @@ export const i18n = createI18n({
})
/** Convenience shorthand: i18n.global */
export const { t, te, d } = i18n.global
const { tm } = i18n.global
export const t: (typeof i18n.global)['t'] = i18n.global.t
export const te: (typeof i18n.global)['te'] = i18n.global.te
export const d: (typeof i18n.global)['d'] = i18n.global.d
const tm = i18n.global.tm
/**
* Safe translation function that returns the fallback message if the key is not found.

View File

@@ -144,7 +144,6 @@ import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
@@ -158,7 +157,10 @@ import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import {
getAssetDisplayName,
resolveDisplayImageDimensions
} from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
@@ -279,12 +281,15 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const displayImageDimensions = computed(() =>
resolveDisplayImageDimensions(asset, imageDimensions.value)
)
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
if (fileKind.value === 'image' && displayImageDimensions.value) {
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)

View File

@@ -223,8 +223,18 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
['film', 'FILM VFI', 'ckpt_name'],
// ---- Ultralytics YOLO detectors (ComfyUI-Impact-Pack) ----
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
// Intentionally NOT mapped to the asset-picker. The cloud asset-ingestion
// metadata for nested model folders (`ultralytics/bbox`, `ultralytics/segm`)
// still has the two known half-bugs described in #12075:
// 1. Tag lookup mismatch (cloud stores combined tags, picker queries split).
// 2. Submitted value mismatch (picker returns basenames, ingest expects
// subdirectory-prefixed `bbox/<file>` / `segm/<file>`).
// PR #12151 re-added the bbox/segm entries before either half was fixed,
// reintroducing the FaceDetailer breakage. Until BE-689 lands the cloud-side
// fixes, leave these disabled so the node falls back to the static combo
// populated from `/api/object_info`.
// ['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
// ['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
// ---- Mel-Band RoFormer audio separation (ComfyUI-MelBandRoFormer) ----
['diffusion_models', 'MelBandRoFormerModelLoader', 'model_name'],

View File

@@ -10,12 +10,14 @@ import {
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename,
getAssetMetadataDimensions,
getAssetModelType,
getAssetSourceUrl,
getAssetStoredFilename,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
getSourceName,
resolveDisplayImageDimensions
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
@@ -417,4 +419,124 @@ describe('assetMetadataUtils', () => {
expect(getAssetCardTitle(asset)).toBe('pretty.png')
})
})
describe('getAssetMetadataDimensions', () => {
it('returns dimensions when width/height are positive integers', () => {
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
expect(getAssetMetadataDimensions(asset)).toEqual({
width: 1024,
height: 768
})
})
it.for([
{ name: 'NaN width', width: Number.NaN, height: 768 },
{
name: 'Infinity height',
width: 1024,
height: Number.POSITIVE_INFINITY
},
{ name: 'zero width', width: 0, height: 768 },
{ name: 'negative height', width: 1024, height: -1 },
{ name: 'fractional width', width: 1024.5, height: 768 },
{ name: 'string width', width: '1024', height: 768 },
{ name: 'missing width', width: undefined, height: 768 }
])('returns undefined for invalid shape: $name', ({ width, height }) => {
const asset = { ...mockAsset, metadata: { width, height } }
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
})
it('returns undefined when metadata is absent', () => {
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
})
it('returns undefined when asset itself is undefined', () => {
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
})
})
describe('resolveDisplayImageDimensions', () => {
const rendered = { width: 512, height: 288 }
it('prefers server metadata dimensions over the rendered natural size', () => {
const asset = { ...mockAsset, metadata: { width: 1920, height: 1080 } }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
width: 1920,
height: 1080
})
})
it('prefers metadata even when a downscaled thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp',
metadata: { width: 1920, height: 1080 }
}
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
width: 1920,
height: 1080
})
})
it('falls back to the rendered natural size when no thumbnail was shown (original served)', () => {
const asset = { ...mockAsset }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('falls back to the rendered natural size on OSS where thumbnail_url equals preview_url (full-res)', () => {
const fullResUrl =
'http://localhost:8188/view?filename=output.png&type=output'
const asset = {
...mockAsset,
thumbnail_url: fullResUrl,
preview_url: fullResUrl
}
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('returns undefined (no label) when metadata is absent and a distinct downscaled thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp'
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('suppresses the fallback for an invalid metadata shape when a distinct thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp',
metadata: { width: 0, height: 1080 }
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('suppresses the fallback when thumbnail_url is present but preview_url is absent', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp'
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('falls back to the rendered natural size when metadata is invalid and no thumbnail guard applies', () => {
const asset = { ...mockAsset, metadata: { width: 0, height: 1080 } }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('returns undefined when neither metadata nor a rendered size is available', () => {
expect(
resolveDisplayImageDimensions(mockAsset, undefined)
).toBeUndefined()
})
it('returns the rendered size when asset is undefined (no thumbnail to guard against)', () => {
expect(resolveDisplayImageDimensions(undefined, rendered)).toEqual(
rendered
)
})
})
})

View File

@@ -216,6 +216,64 @@ export function getAssetCardTitle(asset: AssetItem): string {
return getAssetDisplayFilename(asset)
}
export interface ImageDimensions {
width: number
height: number
}
/**
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
* NaN, Infinity, 0, negatives, and fractional values.
*/
function isValidDimension(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0
}
/**
* Returns the original image dimensions from `asset.metadata.{width,height}`
* when both pass shape validation, otherwise `undefined`. Callers should fall
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
* on runtimes that serve the original file but reports preview size on
* runtimes that serve a downscaled preview.
*/
export function getAssetMetadataDimensions(
asset: AssetItem | undefined
): ImageDimensions | undefined {
const w = asset?.metadata?.width
const h = asset?.metadata?.height
if (isValidDimension(w) && isValidDimension(h)) {
return { width: w, height: h }
}
return undefined
}
/**
* Resolves the image dimensions an asset card should display.
*
* Prefers the server-provided original dimensions from
* {@link getAssetMetadataDimensions}. Only when those are absent does it fall
* back to `renderedNaturalSize` — the natural size of the `<img>` the card
* actually rendered — and only when that rendered image was the original file.
*
* A distinct `thumbnail_url` (one that differs from `preview_url`) means the
* card rendered a downscaled preview, so `renderedNaturalSize` reflects the
* preview's dimensions rather than the asset's. In that case this returns
* `undefined` so the card shows no label rather than a wrong resolution.
* On OSS, `thumbnail_url` and `preview_url` are the same URL (full-res),
* so the guard correctly passes through `renderedNaturalSize`.
*/
export function resolveDisplayImageDimensions(
asset: AssetItem | undefined,
renderedNaturalSize: ImageDimensions | undefined
): ImageDimensions | undefined {
const fromMetadata = getAssetMetadataDimensions(asset)
if (fromMetadata) return fromMetadata
if (asset?.thumbnail_url && asset.thumbnail_url !== asset.preview_url)
return undefined
return renderedNaturalSize
}
/**
* Returns the filename component the cloud `/api/view` endpoint resolves
* for this asset — `hash` when present (cloud assets are hash-keyed

View File

@@ -0,0 +1,83 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [0, 0],
"size": [100, 100],
"flags": {},
"order": 1,
"mode": 0,
"properties": {},
"widgets_values": [0, "randomize", 20]
},
{
"id": 2,
"type": "subgraph-x",
"pos": [300, 0],
"size": [100, 100],
"flags": {},
"order": 0,
"mode": 0,
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "subgraph-x",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "x",
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [0, 0],
"size": [100, 100],
"flags": {},
"order": 0,
"mode": 0,
"properties": {
"models": [
{
"name": "rare_model.safetensors",
"directory": "checkpoints"
}
]
},
"widgets_values": ["some_other_model.safetensors"]
}
],
"links": [],
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
"inputs": [],
"outputs": [],
"widgets": []
}
]
},
"models": [
{
"name": "rare_model.safetensors",
"url": "https://example.com/rare",
"directory": "checkpoints"
}
]
}

View File

@@ -0,0 +1,83 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [0, 0],
"size": [100, 100],
"flags": {},
"order": 1,
"mode": 0,
"properties": {},
"widgets_values": [0, "randomize", 20]
},
{
"id": 2,
"type": "subgraph-x",
"pos": [300, 0],
"size": [100, 100],
"flags": {},
"order": 0,
"mode": 4,
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "subgraph-x",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "x",
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [0, 0],
"size": [100, 100],
"flags": {},
"order": 0,
"mode": 0,
"properties": {
"models": [
{
"name": "rare_model.safetensors",
"directory": "checkpoints"
}
]
},
"widgets_values": ["some_other_model.safetensors"]
}
],
"links": [],
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
"inputs": [],
"outputs": [],
"widgets": []
}
]
},
"models": [
{
"name": "rare_model.safetensors",
"url": "https://example.com/rare",
"directory": "checkpoints"
}
]
}

View File

@@ -34,6 +34,10 @@ const { mockHandles } = vi.hoisted(() => {
executionErrorStore: {
surfaceMissingModels: vi.fn()
},
modelStore: {
loadModelFolders: vi.fn(),
getLoadedModelFolder: vi.fn()
},
modelToNodeStore: {
getCategoryForNodeType: vi.fn()
},
@@ -45,9 +49,14 @@ const { mockHandles } = vi.hoisted(() => {
): MissingModelCandidate[] => []
),
enrichWithEmbeddedMetadata: vi.fn(
(
async (
_candidates: readonly MissingModelCandidate[],
_graphData: ComfyWorkflowJSON
_graphData: ComfyWorkflowJSON,
_checkModelInstalled: (
name: string,
directory: string
) => Promise<boolean>,
_isAssetSupported?: (nodeType: string, widgetName: string) => boolean
) => state.enrichedCandidates
),
verifyAssetSupportedCandidates: vi.fn(
@@ -95,6 +104,10 @@ vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => mockHandles.executionErrorStore
}))
vi.mock('@/stores/modelStore', () => ({
useModelStore: () => mockHandles.modelStore
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => mockHandles.modelToNodeStore
}))
@@ -108,8 +121,16 @@ vi.mock('@/platform/missingModel/missingModelScan', () => ({
mockHandles.scanAllModelCandidates(graph, isAssetSupported, getDirectory),
enrichWithEmbeddedMetadata: (
candidates: readonly MissingModelCandidate[],
graphData: ComfyWorkflowJSON
) => mockHandles.enrichWithEmbeddedMetadata(candidates, graphData),
graphData: ComfyWorkflowJSON,
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
) =>
mockHandles.enrichWithEmbeddedMetadata(
candidates,
graphData,
checkModelInstalled,
isAssetSupported
),
verifyAssetSupportedCandidates: (
candidates: readonly MissingModelCandidate[],
signal: AbortSignal
@@ -165,6 +186,8 @@ describe('missingModelPipeline', () => {
mockHandles.missingModelStore.createVerificationAbortController.mockImplementation(
() => new AbortController()
)
mockHandles.modelStore.loadModelFolders.mockResolvedValue(undefined)
mockHandles.modelStore.getLoadedModelFolder.mockResolvedValue(undefined)
mockHandles.modelToNodeStore.getCategoryForNodeType.mockReturnValue(
undefined
)
@@ -230,7 +253,9 @@ describe('missingModelPipeline', () => {
expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ models: activeModels })
expect.objectContaining({ models: activeModels }),
expect.any(Function),
undefined
)
expect(
mockHandles.executionErrorStore.surfaceMissingModels
@@ -280,7 +305,9 @@ describe('missingModelPipeline', () => {
hash_type: 'sha256'
}
]
})
}),
expect.any(Function),
undefined
)
expect(
mockHandles.executionErrorStore.surfaceMissingModels
@@ -298,7 +325,9 @@ describe('missingModelPipeline', () => {
expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith(
expect.any(Array),
graphData
graphData,
expect.any(Function),
undefined
)
})

View File

@@ -15,6 +15,7 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useModelStore } from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { MissingNodeType } from '@/types/comfy'
@@ -120,7 +121,20 @@ export async function runMissingModelPipeline({
getDirectory
)
const enrichedAll = enrichWithEmbeddedMetadata(candidates, graphData)
const modelStore = useModelStore()
await modelStore.loadModelFolders()
const enrichedAll = await enrichWithEmbeddedMetadata(
candidates,
graphData,
async (name, directory) => {
const folder = await modelStore.getLoadedModelFolder(directory)
const models = folder?.models
return !!(
models && Object.values(models).some((m) => m.file_name === name)
)
},
isCloud ? isAssetBrowserWidget : undefined
)
// Drop candidates whose enclosing subgraph is muted/bypassed. Per-node
// scans only checked each node's own mode; the cascade from an

View File

@@ -15,6 +15,8 @@ import {
verifyAssetSupportedCandidates,
MODEL_FILE_EXTENSIONS
} from '@/platform/missingModel/missingModelScan'
import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/activeSubgraphUnmatchedModel.json' with { type: 'json' }
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
@@ -669,8 +671,11 @@ function makeCandidate(
}
}
const alwaysMissing = async () => false
const alwaysInstalled = async () => true
describe('enrichWithEmbeddedMetadata', () => {
it('enriches existing candidate with url and directory from embedded metadata', () => {
it('enriches existing candidate with url and directory from embedded metadata', async () => {
const candidates = [makeCandidate('model_a.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -704,14 +709,18 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result[0].url).toBe('https://example.com/model_a')
expect(result[0].directory).toBe('checkpoints')
expect(result[0].hash).toBe('abc123')
})
it('does not overwrite existing fields on candidate', () => {
it('does not overwrite existing fields on candidate', async () => {
const candidates = [
makeCandidate('model_a.safetensors', {
directory: 'existing_dir',
@@ -748,13 +757,18 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
// ??= should not overwrite existing values
expect(result[0].url).toBe('https://existing.com')
expect(result[0].directory).toBe('existing_dir')
})
it('does not mutate the original candidates array', () => {
it('does not mutate the original candidates array', async () => {
const candidates = [makeCandidate('model_a.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -787,12 +801,12 @@ describe('enrichWithEmbeddedMetadata', () => {
})
const originalUrl = candidates[0].url
enrichWithEmbeddedMetadata(candidates, graphData)
await enrichWithEmbeddedMetadata(candidates, graphData, alwaysMissing)
expect(candidates[0].url).toBe(originalUrl)
})
it('does not add a candidate for embedded metadata without a live candidate', () => {
it('adds new candidate for embedded model not found by COMBO scan', async () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -824,12 +838,18 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('model_a.safetensors')
expect(result[0].isMissing).toBe(true)
})
it('does not add a candidate from root metadata without live references', () => {
it('does not add candidate when model is already installed', async () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 0,
@@ -849,12 +869,117 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysInstalled
)
expect(result).toHaveLength(0)
})
it('enriches existing candidates from node-sourced metadata', () => {
it('skips embedded models from muted nodes', async () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 2, // NEVER (muted)
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
})
it('drops workflow-level model entries when only referencing nodes are bypassed (other active nodes present)', async () => {
// Regression: a previous `hasActiveNodes` check kept workflow-level
// models in a mixed graph if ANY active node existed, even when every
// node that actually referenced the model was bypassed. The correct
// check drops unmatched workflow-level entries since candidates are
// derived from active-node widgets.
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 2,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 4, // BYPASS — only node referencing the model
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
},
{
id: 2,
type: 'KSampler',
pos: [200, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0, // ALWAYS — unrelated active node
properties: {},
widgets_values: {}
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
})
it('keeps unmatched node-sourced entries in a mixed graph', async () => {
// A node-sourced unmatched entry (sourceNodeType !== '') must survive
// the workflow-level filter. This ensures the simplification does not
// over-filter legitimate per-node missing models.
const candidates = [
makeCandidate('node_model.safetensors', { nodeId: '1' })
]
@@ -890,14 +1015,18 @@ describe('enrichWithEmbeddedMetadata', () => {
models: []
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(1)
expect(result[0].url).toBe('https://example.com/node_model')
expect(result[0].name).toBe('node_model.safetensors')
})
it('does not enrich from muted node metadata', () => {
const candidates = [makeCandidate('model.safetensors')]
it('skips embedded models from bypassed nodes', async () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -909,16 +1038,8 @@ describe('enrichWithEmbeddedMetadata', () => {
size: [100, 100],
flags: {},
order: 0,
mode: 2,
properties: {
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
},
mode: 4, // BYPASS
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
@@ -927,152 +1048,58 @@ describe('enrichWithEmbeddedMetadata', () => {
config: {},
extra: {},
version: 0.4,
models: []
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result[0].url).toBeUndefined()
})
it('does not enrich from bypassed node metadata', () => {
const candidates = [makeCandidate('model.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
models: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 4,
properties: {
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: []
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result[0].url).toBeUndefined()
})
it.for([
{ state: 'muted', ancestorMode: 2 },
{ state: 'bypassed', ancestorMode: 4 }
])(
'does not enrich from metadata inside a $state ancestor subgraph',
({ ancestorMode }) => {
const candidates = [
makeCandidate('shared_model.safetensors', {
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
})
]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 2,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {},
widgets_values: { ckpt_name: 'shared_model.safetensors' }
},
{
id: 2,
type: 'InactiveSubgraph',
pos: [200, 0],
size: [100, 100],
flags: {},
order: 1,
mode: ancestorMode,
properties: {},
widgets_values: {}
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [],
definitions: {
subgraphs: [
{
id: 'InactiveSubgraph',
name: 'InactiveSubgraph',
nodes: [
{
id: 10,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {
models: [
{
name: 'shared_model.safetensors',
url: 'https://example.com/inactive-branch',
directory: 'checkpoints',
hash: 'inactive-hash',
hash_type: 'sha256'
}
]
},
widgets_values: {
ckpt_name: 'shared_model.safetensors'
}
}
],
links: [],
groups: [],
config: {},
extra: {},
inputNode: {},
outputNode: {}
}
]
}
})
]
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result[0].url).toBeUndefined()
expect(result[0].hash).toBeUndefined()
expect(result[0].hashType).toBeUndefined()
}
)
expect(result).toHaveLength(0)
})
it('does not enrich candidates from different-directory metadata', () => {
const candidates = [
makeCandidate('collide_model.safetensors', {
directory: 'checkpoints'
})
]
it('drops workflow-level entries when only reference is in a bypassed subgraph interior', async () => {
// Interior properties.models references the workflow-level model
// but its widget value does not — forcing the workflow-level entry
// down the unmatched path where isModelReferencedByActiveNode
// decides. Previously the helper ignored the bypassed container.
const result = await enrichWithEmbeddedMetadata(
[],
fromAny<ComfyWorkflowJSON, unknown>(bypassedSubgraphUnmatchedModel),
alwaysMissing
)
expect(result).toHaveLength(0)
})
it('keeps workflow-level entries when reference is in an active subgraph interior', async () => {
// Positive control for the bypassed case above: identical fixture
// with container mode=0 must still surface the unmatched workflow-
// level model. Guards against a regression where the ancestor gate
// drops every workflow-level entry regardless of context.
const result = await enrichWithEmbeddedMetadata(
[],
fromAny<ComfyWorkflowJSON, unknown>(activeSubgraphUnmatchedModel),
alwaysMissing
)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('rare_model.safetensors')
})
it('drops workflow-level entries when interior reference is under a different directory', async () => {
// Same name, different directory: the interior's properties.models
// entry is not the same asset as the workflow-level entry, so the
// fallback helper must not treat it as a reference that keeps the
// workflow-level model alive.
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -1105,19 +1132,43 @@ describe('enrichWithEmbeddedMetadata', () => {
{
name: 'collide_model.safetensors',
url: 'https://example.com/collide',
directory: 'loras'
directory: 'checkpoints'
}
]
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const result = await enrichWithEmbeddedMetadata(
[],
graphData,
alwaysMissing
)
expect(result[0].url).toBeUndefined()
expect(result).toHaveLength(0)
})
})
describe('OSS missing model detection (non-Cloud path)', () => {
it('does not detect embedded models without prior candidate scan', () => {
it('scanAllModelCandidates returns empty array when not called (simulating isCloud === false guard)', () => {
// In the app, when isCloud is false, scanAllModelCandidates is not called
// and an empty array is used instead. This test verifies the OSS path
// starts with an empty candidates list.
const isCloud = false
const graph = makeGraph([
makeNode(1, 'CheckpointLoaderSimple', [
makeComboWidget('ckpt_name', 'missing_model.safetensors', [])
])
])
const modelCandidates = isCloud
? scanAllModelCandidates(graph, noAssetSupport)
: []
expect(modelCandidates).toEqual([])
})
it('enrichWithEmbeddedMetadata detects missing embedded models without prior COMBO scan (OSS dialog path)', async () => {
// OSS path: candidates start empty, enrichWithEmbeddedMetadata adds
// missing embedded models so the dialog can show them.
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 2,
@@ -1165,15 +1216,67 @@ describe('OSS missing model detection (non-Cloud path)', () => {
]
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
expect(result).toHaveLength(2)
expect(result.every((c) => c.isMissing === true)).toBe(true)
expect(result.map((c) => c.name)).toEqual([
'sd_xl_base_1.0.safetensors',
'detail_enhancer.safetensors'
])
})
it('enriches live OSS candidates for dialog filtering', () => {
const candidates: MissingModelCandidate[] = [
makeCandidate('missing_model.safetensors')
]
it('enrichWithEmbeddedMetadata sets isMissing=true when isAssetSupported is not provided (OSS)', async () => {
// When isAssetSupported is omitted (OSS), unmatched embedded models
// should have isMissing=true (not undefined), enabling the dialog.
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {},
widgets_values: { ckpt_name: 'missing_model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'missing_model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBe(true)
expect(result[0].isAssetSupported).toBe(false)
})
it('enrichWithEmbeddedMetadata correctly filters for dialog: only isMissing=true with url', async () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -1209,13 +1312,64 @@ describe('OSS missing model detection (non-Cloud path)', () => {
]
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const selectiveInstallCheck = async (name: string) =>
name === 'installed_model.safetensors'
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
selectiveInstallCheck
)
const dialogModels = result.filter((c) => c.isMissing === true && c.url)
expect(dialogModels).toHaveLength(1)
expect(dialogModels[0].name).toBe('missing_model.safetensors')
expect(dialogModels[0].url).toBe('https://example.com/model')
})
it('enrichWithEmbeddedMetadata with isAssetSupported leaves isMissing undefined for asset-supported models (Cloud path)', async () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing,
() => true
)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBeUndefined()
expect(result[0].isAssetSupported).toBe(true)
})
})
const { mockUpdateModelsForNodeType, mockGetAssets } = vi.hoisted(() => ({

View File

@@ -1,7 +1,11 @@
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { FlattenableWorkflowGraph } from '@/platform/workflow/core/utils/workflowFlattening'
import { flattenWorkflowNodes } from '@/platform/workflow/core/utils/workflowFlattening'
import type { MissingModelCandidate, MissingModelViewModel } from './types'
import type {
MissingModelCandidate,
MissingModelViewModel,
EmbeddedModelWithSource
} from './types'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// eslint-disable-next-line import-x/no-restricted-paths
@@ -13,13 +17,13 @@ import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import { getParentExecutionIds } from '@/types/nodeIdentification'
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
models?: ModelFile[]
@@ -66,10 +70,6 @@ function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
function isInactiveMode(mode: number | undefined): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
// Full set of model file extensions used for scanning candidate widgets.
// Intentionally broader than ALLOWED_SUFFIXES in missingModelDownload.ts,
// which restricts which files are eligible for download.
@@ -111,7 +111,11 @@ export function scanAllModelCandidates(
// Skip subgraph container nodes: their promoted widgets are synthetic
// views of interior widgets, which are already scanned via recursion.
if (node.isSubgraphNode?.()) continue
if (isInactiveMode(node.mode)) continue
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
candidates.push(
...scanNodeModelCandidates(
@@ -213,12 +217,14 @@ function scanComboWidget(
}
}
export function enrichWithEmbeddedMetadata(
export async function enrichWithEmbeddedMetadata(
candidates: readonly MissingModelCandidate[],
graphData: MissingModelWorkflowData
): MissingModelCandidate[] {
graphData: MissingModelWorkflowData,
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
): Promise<MissingModelCandidate[]> {
const allNodes = flattenWorkflowNodes(graphData)
const embeddedModels = collectEmbeddedModels(allNodes, graphData)
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
const enriched = candidates.map((c) => ({ ...c }))
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
@@ -234,7 +240,7 @@ export function enrichWithEmbeddedMetadata(
else candidatesByKey.set(nameKey, [c])
}
const deduped: ModelFile[] = []
const deduped: EmbeddedModelWithSource[] = []
const enrichedKeys = new Set<string>()
for (const model of embeddedModels) {
const dedupeKey = `${model.name}::${model.directory}`
@@ -243,60 +249,195 @@ export function enrichWithEmbeddedMetadata(
deduped.push(model)
}
const unmatched: EmbeddedModelWithSource[] = []
for (const model of deduped) {
const dirKey = `${model.name}::${model.directory}`
const exact = candidatesByKey.get(dirKey)
const fallback = candidatesByKey.get(model.name)
const existing = exact?.length ? exact : fallback
if (!existing) continue
for (const c of existing) {
if (c.directory && c.directory !== model.directory) continue
c.directory ??= model.directory
c.url ??= model.url
c.hash ??= model.hash
c.hashType ??= model.hash_type
if (existing) {
for (const c of existing) {
if (c.directory && c.directory !== model.directory) continue
c.directory ??= model.directory
c.url ??= model.url
c.hash ??= model.hash
c.hashType ??= model.hash_type
}
} else {
unmatched.push(model)
}
}
// Workflow-level entries (sourceNodeType === '') survive only when
// some active (non-muted, non-bypassed) node actually references the
// model — not merely because any unrelated active node exists. A
// reference is any widget value (or node.properties.models entry)
// that matches the model name on an active node.
// Hoist the id→node map once; isModelReferencedByActiveNode would
// otherwise rebuild it on every unmatched entry.
const flattenedNodeById = new Map(allNodes.map((n) => [String(n.id), n]))
const activeUnmatched = unmatched.filter(
(m) =>
m.sourceNodeType !== '' ||
isModelReferencedByActiveNode(
m.name,
m.directory,
allNodes,
flattenedNodeById
)
)
const settled = await Promise.allSettled(
activeUnmatched.map(async (model) => {
const installed = await checkModelInstalled(model.name, model.directory)
if (installed) return null
const nodeIsAssetSupported = isAssetSupported
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
: false
return {
nodeId: model.sourceNodeId,
nodeType: model.sourceNodeType,
widgetName: model.sourceWidgetName,
isAssetSupported: nodeIsAssetSupported,
name: model.name,
directory: model.directory,
url: model.url,
hash: model.hash,
hashType: model.hash_type,
isMissing: nodeIsAssetSupported ? undefined : true
} satisfies MissingModelCandidate
})
)
for (const r of settled) {
if (r.status === 'rejected') {
console.warn(
'[Missing Model Pipeline] checkModelInstalled failed:',
r.reason
)
continue
}
if (r.value) enriched.push(r.value)
}
return enriched
}
function collectEmbeddedModels(
function isModelReferencedByActiveNode(
modelName: string,
modelDirectory: string | undefined,
allNodes: ReturnType<typeof flattenWorkflowNodes>,
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
): boolean {
for (const node of allNodes) {
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
if (!isAncestorPathActiveInFlattened(String(node.id), nodeById)) continue
// Require directory agreement when both sides specify one, so a
// same-name entry under a different folder does not keep an
// unrelated workflow-level model alive as missing.
const embeddedModels = (
node.properties as
| { models?: Array<{ name: string; directory?: string }> }
| undefined
)?.models
if (
embeddedModels?.some(
(m) =>
m.name === modelName &&
(modelDirectory === undefined ||
m.directory === undefined ||
m.directory === modelDirectory)
)
) {
return true
}
// widgets_values carries only the name, so directory cannot be
// checked here — fall back to filename matching.
const values = node.widgets_values
if (!values) continue
const valueArray = Array.isArray(values) ? values : Object.values(values)
for (const v of valueArray) {
if (typeof v === 'string' && v === modelName) return true
}
}
return false
}
function isAncestorPathActiveInFlattened(
executionId: string,
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
): boolean {
for (const ancestorId of getParentExecutionIds(executionId)) {
const ancestor = nodeById.get(ancestorId)
if (!ancestor) continue
if (
ancestor.mode === LGraphEventMode.NEVER ||
ancestor.mode === LGraphEventMode.BYPASS
)
return false
}
return true
}
function collectEmbeddedModelsWithSource(
allNodes: ReturnType<typeof flattenWorkflowNodes>,
graphData: MissingModelWorkflowData
): ModelFile[] {
const result: ModelFile[] = []
const nodesById = new Map(allNodes.map((node) => [String(node.id), node]))
): EmbeddedModelWithSource[] {
const result: EmbeddedModelWithSource[] = []
for (const node of allNodes) {
if (!isNodeAndAncestorsActive(node, nodesById)) continue
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
const selected = getSelectedModelsMetadata(node)
if (!selected?.length) continue
result.push(...selected)
for (const model of selected) {
result.push({
...model,
sourceNodeId: node.id,
sourceNodeType: node.type,
sourceWidgetName: findWidgetNameForModel(node, model.name)
})
}
}
if (graphData.models?.length) result.push(...graphData.models)
// Workflow-level model entries have no originating node; sourceNodeId
// remains undefined and empty-string node type/widget are handled by
// groupCandidatesByName (no nodeId → no referencing node entry).
if (graphData.models?.length) {
for (const model of graphData.models) {
result.push({
...model,
sourceNodeType: '',
sourceWidgetName: ''
})
}
}
return result
}
function isNodeAndAncestorsActive(
function findWidgetNameForModel(
node: ReturnType<typeof flattenWorkflowNodes>[number],
nodesById: ReadonlyMap<
string,
ReturnType<typeof flattenWorkflowNodes>[number]
>
): boolean {
if (isInactiveMode(node.mode)) return false
for (const ancestorId of getParentExecutionIds(String(node.id))) {
const ancestor = nodesById.get(ancestorId)
if (isInactiveMode(ancestor?.mode)) return false
modelName: string
): string {
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
for (const [key, val] of Object.entries(node.widgets_values)) {
if (val === modelName) return key
}
return true
return ''
}
interface AssetVerifier {

View File

@@ -1,4 +1,7 @@
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ModelFile,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* A single (node, widget, model) binding detected by the missing model pipeline.
@@ -25,6 +28,13 @@ export interface MissingModelCandidate {
isMissing: boolean | undefined
}
export interface EmbeddedModelWithSource extends ModelFile {
/** Undefined for workflow-level models not tied to a specific node. */
sourceNodeId?: NodeId
sourceNodeType: string
sourceWidgetName: string
}
/** View model grouping multiple candidate references under a single model name. */
export interface MissingModelViewModel {
name: string

View File

@@ -31,6 +31,11 @@ export interface Member {
email: string
joined_at: string
role: WorkspaceRole
// True when this member is the workspace's original owner/creator
// (member.id == workspace.created_by_user_id). Gates the creator-only
// billing lifecycle actions (cancel / reactivate / downgrade).
// Optional: the cloud OpenAPI does not carry this field yet.
is_original_owner?: boolean
}
interface PaginationInfo {

View File

@@ -113,7 +113,11 @@
button-variant="gradient"
/>
<Button
v-if="showSubscribeAction && !isPersonalWorkspace"
v-if="
showSubscribeAction &&
!isPersonalWorkspace &&
(!isCancelled || permissions.canManageSubscriptionLifecycle)
"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"

View File

@@ -128,9 +128,10 @@
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<!-- Cancelled state: show only Resubscribe button -->
<!-- Cancelled state: reactivation is original-owner-only. -->
<template v-if="isCancelled">
<Button
v-if="permissions.canManageSubscriptionLifecycle"
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal"
@@ -161,7 +162,7 @@
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-if="!isFreeTierPlan"
v-if="!isFreeTierPlan && planMenuItems.length > 0"
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
@@ -513,15 +514,23 @@ const subscriptionTierName = computed(() => {
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
const planMenuItems = computed(() => [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
}
}
])
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
// and the "more options" button is hidden (see template).
const planMenuItems = computed(() =>
permissions.value.canManageSubscriptionLifecycle
? [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(
subscription.value?.endDate ?? undefined
)
}
}
]
: []
)
const tierKey = computed(() => {
const tier = subscriptionTier.value

View File

@@ -82,7 +82,8 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
name: 'Owner User',
email: 'owner@example.com',
role: 'owner' as const,
joinDate: new Date(0)
joinDate: new Date(0),
isOriginalOwner: true
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
@@ -153,6 +154,7 @@ function createMember(
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}

View File

@@ -21,6 +21,7 @@ function createMember(
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}

View File

@@ -111,7 +111,8 @@ export function useMembersPanel() {
name: userDisplayName.value ?? '',
email: userEmail.value ?? '',
role: 'owner' as const,
joinDate: new Date(0)
joinDate: new Date(0),
isOriginalOwner: true
}))
const searchQuery = ref('')

View File

@@ -2,15 +2,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
const mockActiveWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
const mockStore = vi.hoisted(() => ({
activeWorkspace: null as WorkspaceWithRole | null,
isCurrentUserOriginalOwner: false,
ensureMembersLoaded: vi.fn()
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get activeWorkspace() {
return mockActiveWorkspace.value
}
return mockStore.activeWorkspace
},
get isCurrentUserOriginalOwner() {
return mockStore.isCurrentUserOriginalOwner
},
ensureMembersLoaded: mockStore.ensureMembersLoaded
})
}))
@@ -46,14 +52,20 @@ async function loadComposable() {
return module.useWorkspaceUI()
}
function resetStore() {
mockStore.activeWorkspace = null
mockStore.isCurrentUserOriginalOwner = false
mockStore.ensureMembersLoaded.mockReset()
}
describe('useWorkspaceUI', () => {
beforeEach(() => {
vi.resetModules()
mockActiveWorkspace.value = null
resetStore()
})
afterEach(() => {
mockActiveWorkspace.value = null
resetStore()
})
describe('when no active workspace', () => {
@@ -71,7 +83,7 @@ describe('useWorkspaceUI', () => {
describe('personal workspace', () => {
beforeEach(() => {
mockActiveWorkspace.value = personalWorkspace
mockStore.activeWorkspace = personalWorkspace
})
it('grants billing access but disables team management', async () => {
@@ -119,7 +131,7 @@ describe('useWorkspaceUI', () => {
describe('team workspace as owner', () => {
beforeEach(() => {
mockActiveWorkspace.value = teamOwnerWorkspace
mockStore.activeWorkspace = teamOwnerWorkspace
})
it('grants full management permissions', async () => {
@@ -159,7 +171,7 @@ describe('useWorkspaceUI', () => {
describe('team workspace as member', () => {
beforeEach(() => {
mockActiveWorkspace.value = teamMemberWorkspace
mockStore.activeWorkspace = teamMemberWorkspace
})
it('restricts management actions while allowing leave', async () => {
@@ -195,9 +207,60 @@ describe('useWorkspaceUI', () => {
})
})
// Drives off the members-list self-row original-owner signal, surfaced by the
// store getter `isCurrentUserOriginalOwner`.
describe('subscription lifecycle (creator-only)', () => {
it('grants lifecycle to the personal-workspace sole owner', async () => {
mockStore.activeWorkspace = personalWorkspace
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
})
it('grants lifecycle to a team owner who is the original owner', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = true
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscription).toBe(true)
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
})
it('withholds lifecycle from a promoted (non-creator) team owner', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = false
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscription).toBe(true)
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('fails closed while the members list is still loading', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = false
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('withholds lifecycle from members', async () => {
mockStore.activeWorkspace = teamMemberWorkspace
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('delegates member loading to the store when a team workspace becomes active', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
await loadComposable()
expect(mockStore.ensureMembersLoaded).toHaveBeenCalled()
})
it('does not load members for a personal workspace', async () => {
mockStore.activeWorkspace = personalWorkspace
await loadComposable()
expect(mockStore.ensureMembersLoaded).not.toHaveBeenCalled()
})
})
describe('shared instance', () => {
it('returns the same composable state for multiple callers within a test', async () => {
mockActiveWorkspace.value = teamOwnerWorkspace
mockStore.activeWorkspace = teamOwnerWorkspace
const first = await loadComposable()
const second = await loadComposable()

View File

@@ -1,4 +1,4 @@
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
@@ -14,6 +14,10 @@ interface WorkspacePermissions {
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
// Creator-only subscription lifecycle: cancel / reactivate / downgrade.
// Any owner has `canManageSubscription` (manage payment, top-up, change
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
canManageSubscriptionLifecycle: boolean
canTopUp: boolean
}
@@ -34,7 +38,8 @@ interface WorkspaceUIConfig {
function getPermissions(
type: WorkspaceType,
role: WorkspaceRole
role: WorkspaceRole,
isOriginalOwner: boolean
): WorkspacePermissions {
if (type === 'personal') {
return {
@@ -46,6 +51,8 @@ function getPermissions(
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true,
// Personal workspace is single-member: the user is the sole owner/creator.
canManageSubscriptionLifecycle: true,
canTopUp: true
}
}
@@ -60,6 +67,7 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canManageSubscriptionLifecycle: isOriginalOwner,
canTopUp: true
}
}
@@ -74,6 +82,7 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
}
}
@@ -145,8 +154,26 @@ function useWorkspaceUIInternal() {
() => store.activeWorkspace?.role ?? 'owner'
)
// The original-owner signal lives on the members-list self-row, so a team
// workspace's members must be loaded before its lifecycle gate can resolve.
// The store dedupes in-flight/already-loaded requests and logs failures;
// until members arrive the getter fails closed.
watch(
() => store.activeWorkspace?.id,
() => {
if (store.activeWorkspace?.type === 'team') {
void store.ensureMembersLoaded()
}
},
{ immediate: true }
)
const permissions = computed<WorkspacePermissions>(() =>
getPermissions(workspaceType.value, workspaceRole.value)
getPermissions(
workspaceType.value,
workspaceRole.value,
store.isCurrentUserOriginalOwner
)
)
const uiConfig = computed<WorkspaceUIConfig>(() =>

View File

@@ -29,6 +29,15 @@ vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
}))
// Mock current user (drives the original-owner self-row match by email)
const mockCurrentUser = vi.hoisted(() => ({
userEmail: { value: null as string | null }
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ userEmail: mockCurrentUser.userEmail })
}))
// Mock workspaceApi
const mockWorkspaceApi = vi.hoisted(() => ({
list: vi.fn(),
@@ -122,6 +131,7 @@ describe('useTeamWorkspaceStore', () => {
vi.clearAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
sessionStorage.clear()
mockCurrentUser.userEmail.value = null
// Reset workspaceAuthStore mock state
mockWorkspaceAuthStore.currentWorkspace = null
@@ -680,6 +690,193 @@ describe('useTeamWorkspaceStore', () => {
})
})
describe('ensureMembersLoaded', () => {
const memberRow = {
id: 'user-1',
name: 'Owner',
email: 'owner@test.com',
joined_at: '2024-01-01T00:00:00Z'
}
function mockMembersResponse() {
mockWorkspaceApi.listMembers.mockResolvedValue({
members: [memberRow],
pagination: { offset: 0, limit: 50, total: 1 }
})
}
async function activateTeamWorkspace() {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
return store
}
it('loads members for a team workspace that is not yet loaded', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
expect(store.members).toHaveLength(1)
})
it('does not load members again once loaded', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
})
it('dedupes concurrent calls into a single request', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await Promise.all([
store.ensureMembersLoaded(),
store.ensureMembersLoaded()
])
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
})
it('does not load members for a personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
})
it('logs a failed request and retries on the next call', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockWorkspaceApi.listMembers.mockRejectedValueOnce(new Error('boom'))
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
expect(consoleError).toHaveBeenCalled()
expect(store.members).toHaveLength(0)
mockMembersResponse()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(2)
expect(store.members).toHaveLength(1)
consoleError.mockRestore()
})
})
describe('isCurrentUserOriginalOwner', () => {
async function loadTeamWithMembers(
members: Array<{
id: string
name: string
email: string
joined_at: string
is_original_owner?: boolean
}>
) {
mockWorkspaceApi.listMembers.mockResolvedValue({
members,
pagination: { offset: 0, limit: 50, total: members.length }
})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchMembers()
return store
}
const ownerSelf = {
id: 'user-1',
name: 'Owner',
email: 'owner@test.com',
joined_at: '2024-01-01T00:00:00Z',
role: 'owner' as const,
is_original_owner: true
}
const promotedSelf = { ...ownerSelf, is_original_owner: false }
it('is true when the self-row is the original owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
it('matches the self-row by email case-insensitively', async () => {
mockCurrentUser.userEmail.value = 'OWNER@TEST.COM'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
it('is false when the self-row is a promoted (non-creator) owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const store = await loadTeamWithMembers([promotedSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when the self-row omits is_original_owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const { is_original_owner: _omitted, ...selfWithoutFlag } = ownerSelf
const store = await loadTeamWithMembers([selfWithoutFlag])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('is false when no member row matches the current user', async () => {
mockCurrentUser.userEmail.value = 'someone-else@test.com'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when members are not loaded', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when the current user email is unknown', async () => {
mockCurrentUser.userEmail.value = null
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('recomputes reactively when the self-row arrives after an empty read', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
mockWorkspaceApi.listMembers.mockResolvedValue({
members: [ownerSelf],
pagination: { offset: 0, limit: 50, total: 1 }
})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isCurrentUserOriginalOwner).toBe(false)
await store.fetchMembers()
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
})
describe('invite actions', () => {
it('fetchPendingInvites updates active workspace invites', async () => {
const mockInvites = [

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
@@ -21,6 +22,7 @@ export interface WorkspaceMember {
email: string
joinDate: Date
role: 'owner' | 'member'
isOriginalOwner: boolean
}
export interface PendingInvite {
@@ -49,7 +51,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
name: member.name,
email: member.email,
joinDate: new Date(member.joined_at),
role: member.role
role: member.role,
isOriginalOwner: member.is_original_owner ?? false
}
}
@@ -146,6 +149,18 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
() => activeWorkspace.value?.members ?? []
)
// True when the current user is the active workspace's original owner,
// resolved from the self-row of the loaded members list. Matches by email
// (the stable current-user join key; member.id is a cloud user id, not the
// Firebase uid). Fails closed when members are not loaded or no self-row
// matches, so lifecycle gating stays hidden until the real signal arrives.
const isCurrentUserOriginalOwner = computed(() => {
const email = useCurrentUser().userEmail.value?.toLowerCase()
if (!email) return false
const selfRow = members.value.find((m) => m.email.toLowerCase() === email)
return selfRow?.isOriginalOwner ?? false
})
const pendingInvites = computed<PendingInvite[]>(
() => activeWorkspace.value?.pendingInvites ?? []
)
@@ -507,6 +522,36 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
return members
}
// Tracks which team workspaces have already loaded their members so the
// lifecycle gate resolves without redundant or duplicate fetches.
const loadedMemberWorkspaceIds = new Set<string>()
let inFlightMembersWorkspaceId: string | null = null
/**
* Load the active team workspace's members once. No-ops for personal or
* already-loaded workspaces and dedupes concurrent calls. A failed request is
* logged and leaves the workspace unloaded so a later call retries.
*/
async function ensureMembersLoaded(): Promise<void> {
const workspaceId = activeWorkspaceId.value
if (!workspaceId) return
if (activeWorkspace.value?.type === 'personal') return
if (loadedMemberWorkspaceIds.has(workspaceId)) return
if (inFlightMembersWorkspaceId === workspaceId) return
inFlightMembersWorkspaceId = workspaceId
try {
await fetchMembers()
loadedMemberWorkspaceIds.add(workspaceId)
} catch (e) {
console.error('Failed to load workspace members', e)
} finally {
if (inFlightMembersWorkspaceId === workspaceId) {
inFlightMembersWorkspaceId = null
}
}
}
/**
* Remove a member from the current workspace.
*/
@@ -652,6 +697,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
ownedWorkspacesCount,
canCreateWorkspace,
members,
isCurrentUserOriginalOwner,
pendingInvites,
totalMemberSlots,
isInviteLimitReached,
@@ -675,6 +721,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Member Actions
fetchMembers,
ensureMembersLoaded,
removeMember,
// Invite Actions