Compare commits

..

3 Commits

Author SHA1 Message Date
jaeone94
98727913fa Merge branch 'main' into jaeone/models-metadata-only 2026-06-20 03:48:20 +09:00
jaeone94
a649210956 Merge branch 'main' into jaeone/models-metadata-only 2026-06-19 17:32:29 +09:00
jaeone94
490cfd7f3c fix: limit workflow models to metadata enrichment 2026-06-19 14:55:22 +09:00
32 changed files with 385 additions and 1418 deletions

View File

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

View File

@@ -4,6 +4,7 @@ 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
@@ -34,6 +35,56 @@ 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)
@@ -347,33 +398,34 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
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ード
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')
@@ -382,7 +434,9 @@ 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.safetensors']
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
await route.fulfill({ response, json: objectInfo })
})
@@ -151,21 +151,11 @@ 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,
modelFoldersResponse,
refreshButton.click()
])
await Promise.all([objectInfoResponse, refreshButton.click()])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()

View File

@@ -1,7 +1,6 @@
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'
@@ -12,9 +11,7 @@ interface ConfirmDialogOptions {
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
export function showConfirmDialog(
options: ConfirmDialogOptions = {}
): DialogInstance {
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { key, headerProps, props, footerProps } = options
return dialogStore.showDialog({

View File

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

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, Ref } from 'vue'
import type { CSSProperties } from 'vue'
import { ref } from 'vue'
interface Rect {
@@ -28,26 +28,7 @@ interface ClippingOptions {
margin?: number
}
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 {
export const useDomClipping = (options: ClippingOptions = {}) => {
const style = ref<CSSProperties>({})
const { margin = 4 } = options

View File

@@ -1,4 +1,4 @@
import type { ComputedRef, CSSProperties, Ref } from 'vue'
import type { CSSProperties, Ref } from 'vue'
import { computed, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
@@ -8,23 +8,10 @@ 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'
export type ResizeDirection =
type ResizeDirection =
| 'top'
| 'bottom'
| 'left'
@@ -17,17 +17,6 @@ export 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. */
@@ -275,6 +264,17 @@ 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

@@ -0,0 +1,26 @@
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,6 +36,10 @@ 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,10 +154,8 @@ export const i18n = createI18n({
})
/** Convenience shorthand: 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
export const { t, te, d } = i18n.global
const { tm } = i18n.global
/**
* Safe translation function that returns the fallback message if the key is not found.

View File

@@ -144,6 +144,7 @@ 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,
@@ -157,10 +158,7 @@ import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import {
getAssetDisplayName,
resolveDisplayImageDimensions
} from '../utils/assetMetadataUtils'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
@@ -281,15 +279,12 @@ 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 ''
if (fileKind.value === 'image' && displayImageDimensions.value) {
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
// 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 (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)

View File

@@ -223,18 +223,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
['film', 'FILM VFI', 'ckpt_name'],
// ---- Ultralytics YOLO detectors (ComfyUI-Impact-Pack) ----
// 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'],
['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,14 +10,12 @@ import {
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename,
getAssetMetadataDimensions,
getAssetModelType,
getAssetSourceUrl,
getAssetStoredFilename,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName,
resolveDisplayImageDimensions
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
@@ -419,124 +417,4 @@ 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,64 +216,6 @@ 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

@@ -1,83 +0,0 @@
{
"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

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

View File

@@ -15,7 +15,6 @@ 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'
@@ -121,20 +120,7 @@ export async function runMissingModelPipeline({
getDirectory
)
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
)
const enrichedAll = enrichWithEmbeddedMetadata(candidates, graphData)
// 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,8 +15,6 @@ 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'
@@ -671,11 +669,8 @@ function makeCandidate(
}
}
const alwaysMissing = async () => false
const alwaysInstalled = async () => true
describe('enrichWithEmbeddedMetadata', () => {
it('enriches existing candidate with url and directory from embedded metadata', async () => {
it('enriches existing candidate with url and directory from embedded metadata', () => {
const candidates = [makeCandidate('model_a.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -709,18 +704,14 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
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', async () => {
it('does not overwrite existing fields on candidate', () => {
const candidates = [
makeCandidate('model_a.safetensors', {
directory: 'existing_dir',
@@ -757,18 +748,13 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
// ??= 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', async () => {
it('does not mutate the original candidates array', () => {
const candidates = [makeCandidate('model_a.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -801,12 +787,12 @@ describe('enrichWithEmbeddedMetadata', () => {
})
const originalUrl = candidates[0].url
await enrichWithEmbeddedMetadata(candidates, graphData, alwaysMissing)
enrichWithEmbeddedMetadata(candidates, graphData)
expect(candidates[0].url).toBe(originalUrl)
})
it('adds new candidate for embedded model not found by COMBO scan', async () => {
it('does not add a candidate for embedded metadata without a live candidate', () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -838,18 +824,12 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('model_a.safetensors')
expect(result[0].isMissing).toBe(true)
expect(result).toHaveLength(0)
})
it('does not add candidate when model is already installed', async () => {
it('does not add a candidate from root metadata without live references', () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 0,
@@ -869,117 +849,12 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysInstalled
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(0)
})
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.
it('enriches existing candidates from node-sourced metadata', () => {
const candidates = [
makeCandidate('node_model.safetensors', { nodeId: '1' })
]
@@ -1015,18 +890,14 @@ describe('enrichWithEmbeddedMetadata', () => {
models: []
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('node_model.safetensors')
expect(result[0].url).toBe('https://example.com/node_model')
})
it('skips embedded models from bypassed nodes', async () => {
const candidates: MissingModelCandidate[] = []
it('does not enrich from muted node metadata', () => {
const candidates = [makeCandidate('model.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -1038,8 +909,16 @@ describe('enrichWithEmbeddedMetadata', () => {
size: [100, 100],
flags: {},
order: 0,
mode: 4, // BYPASS
properties: {},
mode: 2,
properties: {
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
@@ -1048,58 +927,152 @@ describe('enrichWithEmbeddedMetadata', () => {
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
models: []
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(0)
expect(result[0].url).toBeUndefined()
})
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
)
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: [
{
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: []
})
expect(result).toHaveLength(0)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result[0].url).toBeUndefined()
})
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
)
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', {
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: {}
}
]
}
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('rare_model.safetensors')
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
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.
expect(result[0].url).toBeUndefined()
expect(result[0].hash).toBeUndefined()
expect(result[0].hashType).toBeUndefined()
}
)
it('does not enrich candidates from different-directory metadata', () => {
const candidates = [
makeCandidate('collide_model.safetensors', {
directory: 'checkpoints'
})
]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -1132,43 +1105,19 @@ describe('enrichWithEmbeddedMetadata', () => {
{
name: 'collide_model.safetensors',
url: 'https://example.com/collide',
directory: 'checkpoints'
directory: 'loras'
}
]
})
const result = await enrichWithEmbeddedMetadata(
[],
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(0)
expect(result[0].url).toBeUndefined()
})
})
describe('OSS missing model detection (non-Cloud path)', () => {
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.
it('does not detect embedded models without prior candidate scan', () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 2,
@@ -1216,67 +1165,15 @@ describe('OSS missing model detection (non-Cloud path)', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
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'
])
expect(result).toHaveLength(0)
})
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[] = []
it('enriches live OSS candidates for dialog filtering', () => {
const candidates: MissingModelCandidate[] = [
makeCandidate('missing_model.safetensors')
]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -1312,64 +1209,13 @@ describe('OSS missing model detection (non-Cloud path)', () => {
]
})
const selectiveInstallCheck = async (name: string) =>
name === 'installed_model.safetensors'
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
selectiveInstallCheck
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
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,11 +1,7 @@
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,
EmbeddedModelWithSource
} from './types'
import type { MissingModelCandidate, MissingModelViewModel } 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
@@ -17,13 +13,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[]
@@ -70,6 +66,10 @@ 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,11 +111,7 @@ 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 (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
if (isInactiveMode(node.mode)) continue
candidates.push(
...scanNodeModelCandidates(
@@ -217,14 +213,12 @@ function scanComboWidget(
}
}
export async function enrichWithEmbeddedMetadata(
export function enrichWithEmbeddedMetadata(
candidates: readonly MissingModelCandidate[],
graphData: MissingModelWorkflowData,
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
): Promise<MissingModelCandidate[]> {
graphData: MissingModelWorkflowData
): MissingModelCandidate[] {
const allNodes = flattenWorkflowNodes(graphData)
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
const embeddedModels = collectEmbeddedModels(allNodes, graphData)
const enriched = candidates.map((c) => ({ ...c }))
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
@@ -240,7 +234,7 @@ export async function enrichWithEmbeddedMetadata(
else candidatesByKey.set(nameKey, [c])
}
const deduped: EmbeddedModelWithSource[] = []
const deduped: ModelFile[] = []
const enrichedKeys = new Set<string>()
for (const model of embeddedModels) {
const dedupeKey = `${model.name}::${model.directory}`
@@ -249,195 +243,60 @@ export async 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) {
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)
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
}
}
// 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 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(
function collectEmbeddedModels(
allNodes: ReturnType<typeof flattenWorkflowNodes>,
graphData: MissingModelWorkflowData
): EmbeddedModelWithSource[] {
const result: EmbeddedModelWithSource[] = []
): ModelFile[] {
const result: ModelFile[] = []
const nodesById = new Map(allNodes.map((node) => [String(node.id), node]))
for (const node of allNodes) {
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
if (!isNodeAndAncestorsActive(node, nodesById)) continue
const selected = getSelectedModelsMetadata(node)
if (!selected?.length) continue
for (const model of selected) {
result.push({
...model,
sourceNodeId: node.id,
sourceNodeType: node.type,
sourceWidgetName: findWidgetNameForModel(node, model.name)
})
}
result.push(...selected)
}
// 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: ''
})
}
}
if (graphData.models?.length) result.push(...graphData.models)
return result
}
function findWidgetNameForModel(
function isNodeAndAncestorsActive(
node: ReturnType<typeof flattenWorkflowNodes>[number],
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
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
}
return ''
return true
}
interface AssetVerifier {

View File

@@ -1,7 +1,4 @@
import type {
ModelFile,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* A single (node, widget, model) binding detected by the missing model pipeline.
@@ -28,13 +25,6 @@ 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,11 +31,6 @@ 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,11 +113,7 @@
button-variant="gradient"
/>
<Button
v-if="
showSubscribeAction &&
!isPersonalWorkspace &&
(!isCancelled || permissions.canManageSubscriptionLifecycle)
"
v-if="showSubscribeAction && !isPersonalWorkspace"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,21 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
const mockStore = vi.hoisted(() => ({
activeWorkspace: null as WorkspaceWithRole | null,
isCurrentUserOriginalOwner: false,
ensureMembersLoaded: vi.fn()
const mockActiveWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get activeWorkspace() {
return mockStore.activeWorkspace
},
get isCurrentUserOriginalOwner() {
return mockStore.isCurrentUserOriginalOwner
},
ensureMembersLoaded: mockStore.ensureMembersLoaded
return mockActiveWorkspace.value
}
})
}))
@@ -52,20 +46,14 @@ async function loadComposable() {
return module.useWorkspaceUI()
}
function resetStore() {
mockStore.activeWorkspace = null
mockStore.isCurrentUserOriginalOwner = false
mockStore.ensureMembersLoaded.mockReset()
}
describe('useWorkspaceUI', () => {
beforeEach(() => {
vi.resetModules()
resetStore()
mockActiveWorkspace.value = null
})
afterEach(() => {
resetStore()
mockActiveWorkspace.value = null
})
describe('when no active workspace', () => {
@@ -83,7 +71,7 @@ describe('useWorkspaceUI', () => {
describe('personal workspace', () => {
beforeEach(() => {
mockStore.activeWorkspace = personalWorkspace
mockActiveWorkspace.value = personalWorkspace
})
it('grants billing access but disables team management', async () => {
@@ -131,7 +119,7 @@ describe('useWorkspaceUI', () => {
describe('team workspace as owner', () => {
beforeEach(() => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockActiveWorkspace.value = teamOwnerWorkspace
})
it('grants full management permissions', async () => {
@@ -171,7 +159,7 @@ describe('useWorkspaceUI', () => {
describe('team workspace as member', () => {
beforeEach(() => {
mockStore.activeWorkspace = teamMemberWorkspace
mockActiveWorkspace.value = teamMemberWorkspace
})
it('restricts management actions while allowing leave', async () => {
@@ -207,60 +195,9 @@ 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 () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockActiveWorkspace.value = teamOwnerWorkspace
const first = await loadComposable()
const second = await loadComposable()

View File

@@ -1,4 +1,4 @@
import { computed, watch } from 'vue'
import { computed } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
@@ -14,10 +14,6 @@ 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
}
@@ -38,8 +34,7 @@ interface WorkspaceUIConfig {
function getPermissions(
type: WorkspaceType,
role: WorkspaceRole,
isOriginalOwner: boolean
role: WorkspaceRole
): WorkspacePermissions {
if (type === 'personal') {
return {
@@ -51,8 +46,6 @@ function getPermissions(
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true,
// Personal workspace is single-member: the user is the sole owner/creator.
canManageSubscriptionLifecycle: true,
canTopUp: true
}
}
@@ -67,7 +60,6 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canManageSubscriptionLifecycle: isOriginalOwner,
canTopUp: true
}
}
@@ -82,7 +74,6 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
}
}
@@ -154,26 +145,8 @@ 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,
store.isCurrentUserOriginalOwner
)
getPermissions(workspaceType.value, workspaceRole.value)
)
const uiConfig = computed<WorkspaceUIConfig>(() =>

View File

@@ -29,15 +29,6 @@ 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(),
@@ -131,7 +122,6 @@ describe('useTeamWorkspaceStore', () => {
vi.clearAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
sessionStorage.clear()
mockCurrentUser.userEmail.value = null
// Reset workspaceAuthStore mock state
mockWorkspaceAuthStore.currentWorkspace = null
@@ -690,193 +680,6 @@ 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,7 +1,6 @@
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'
@@ -22,7 +21,6 @@ export interface WorkspaceMember {
email: string
joinDate: Date
role: 'owner' | 'member'
isOriginalOwner: boolean
}
export interface PendingInvite {
@@ -51,8 +49,7 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
name: member.name,
email: member.email,
joinDate: new Date(member.joined_at),
role: member.role,
isOriginalOwner: member.is_original_owner ?? false
role: member.role
}
}
@@ -149,18 +146,6 @@ 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 ?? []
)
@@ -522,36 +507,6 @@ 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.
*/
@@ -697,7 +652,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
ownedWorkspacesCount,
canCreateWorkspace,
members,
isCurrentUserOriginalOwner,
pendingInvites,
totalMemberSlots,
isInviteLimitReached,
@@ -721,7 +675,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Member Actions
fetchMembers,
ensureMembersLoaded,
removeMember,
// Invite Actions