From 10355acce5e04186071c38868653dc20fb920af6 Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Mon, 7 Sep 2026 01:03:38 -0700 Subject: [PATCH 1/9] feat: add desktop performance testing workflow Amp-Thread-ID: https://ampcode.com/threads/T-01a079f8-8d36-74c5-ae41-6eddf1d49af6 Co-authored-by: Amp --- POSTHOG_TELEMETRY_EVENTS.md | 254 +++++ locales/en.json | 45 + locales/zh.json | 45 + src/main/host/panelView.ts | 7 +- src/main/host/registry.ts | 3 + src/main/lib/benchmarkWorkflows.test.ts | 387 ++++++++ src/main/lib/benchmarkWorkflows.ts | 351 +++++++ src/main/lib/hardwareTap.test.ts | 27 + src/main/lib/hardwareTap.ts | 32 + src/main/lib/ipc/registerAppHandlers.ts | 108 ++- src/main/lib/ipc/registerSessionHandlers.ts | 14 +- .../lib/ipc/sessionActions/launch.test.ts | 78 +- src/main/lib/ipc/sessionActions/launch.ts | 166 ++-- src/main/lib/ipc/sessionActions/types.ts | 2 + src/main/lib/ipc/shared.ts | 21 +- src/main/popups/titlePopup.test.ts | 83 +- src/main/popups/titlePopup.ts | 21 +- src/preload/api.ts | 6 + src/preload/comfyTitleBarPreload.ts | 8 +- .../src/comfyTitleBar/TitleBarApp.vue | 8 +- .../src/composables/useInstallContextMenu.ts | 32 + .../composables/useWorkspaceInstallScope.ts | 56 ++ src/renderer/src/lib/openInstallManager.ts | 25 + src/renderer/src/panel/PanelApp.test.ts | 527 +++++++++++ src/renderer/src/panel/PanelApp.vue | 14 +- src/renderer/src/panel/usePanelOverlays.ts | 2 + src/renderer/src/views/ChooserView.test.ts | 12 +- src/renderer/src/views/ChooserView.vue | 244 +---- src/renderer/src/views/InstallWizardModal.vue | 2 +- .../src/views/PerformanceBenchmarksView.vue | 865 ++++++++++++++++++ .../src/views/chooser/ChooserInstallTile.vue | 37 +- .../devplatform/WorkspaceSelectorBar.vue | 166 ++++ src/types/ipc.ts | 47 + 33 files changed, 3366 insertions(+), 329 deletions(-) create mode 100644 POSTHOG_TELEMETRY_EVENTS.md create mode 100644 src/main/lib/benchmarkWorkflows.test.ts create mode 100644 src/main/lib/benchmarkWorkflows.ts create mode 100644 src/renderer/src/composables/useWorkspaceInstallScope.ts create mode 100644 src/renderer/src/lib/openInstallManager.ts create mode 100644 src/renderer/src/views/PerformanceBenchmarksView.vue create mode 100644 src/renderer/src/views/devplatform/WorkspaceSelectorBar.vue diff --git a/POSTHOG_TELEMETRY_EVENTS.md b/POSTHOG_TELEMETRY_EVENTS.md new file mode 100644 index 000000000..ff0305fe2 --- /dev/null +++ b/POSTHOG_TELEMETRY_EVENTS.md @@ -0,0 +1,254 @@ +# PostHog telemetry events + +Repository inventory based on commit `379dc684`. + +> [!NOTE] +> Unpackaged development builds (`pnpm dev`) do not send PostHog writes. The +> telemetry initializer sets `suppressEmit = true` when `isPackaged` is false. + +PostHog writes are centralized in `src/main/lib/telemetry.ts`. Renderer events +are forwarded to it through `src/main/lib/ipc/registerTelemetryHandlers.ts`. + +## Fixed event names + +### PostHog-native + +- `$exception` +- `$identify` + +`comfy.desktop.exception.error` is an internal rate-limit and Datadog name; +PostHog receives `$exception`. + +### Authentication and identity + +- `app:user_logged_in` +- `comfy.desktop.auth.sign_in_started` +- `comfy.desktop.auth.sign_in_failed` +- `comfy.desktop.identity.login_attributed` +- `comfy.desktop.identity.pending_consensus_expired` +- `comfy.desktop.person.set` + +### Application and sessions + +- `comfy.desktop.app.first_launch` +- `comfy.desktop.app.language_resolved` +- `comfy.desktop.session.started` +- `comfy.desktop.session.ended` +- `comfy.desktop.session.instance_started` +- `comfy.desktop.session.installation_started` +- `comfy.desktop.session.installs_inventory` +- `comfy.desktop.session.snapshot_history` +- `comfy.desktop.session.storage_detected` +- `comfy.desktop.session.system_info` + +### App updates + +- `comfy.desktop.app_update.available` +- `comfy.desktop.app_update.checked` +- `comfy.desktop.app_update.download_started` +- `comfy.desktop.app_update.download_complete` +- `comfy.desktop.app_update.error` +- `comfy.desktop.app_update.ignored_not_newer` +- `comfy.desktop.app_update.install_triggered` +- `comfy.desktop.app_update.startup_install` +- `comfy.desktop.app_update.startup_install_skipped` +- `comfy.desktop.app_update.startup_install_backstop_recovered` + +### First-use funnel + +- `comfy.desktop.first_use.abandoned` +- `comfy.desktop.first_use.completed` +- `comfy.desktop.first_use.consent_decision` +- `comfy.desktop.first_use.fork_chosen` +- `comfy.desktop.first_use.local_branch_chosen` +- `comfy.desktop.first_use.mirrors_chosen` +- `comfy.desktop.first_use.step_viewed` +- `comfy.desktop.first_use.why_cloud_opened` +- `comfy.desktop.first_use.why_cloud_action` + +### Installation + +- `comfy.desktop.install.completed` +- `comfy.desktop.install.disk_warning.response` +- `comfy.desktop.install.dispatched` +- `comfy.desktop.install.express.started` +- `comfy.desktop.install.express.fallback` +- `comfy.desktop.install.flow.opened` +- `comfy.desktop.install.guardrail.blocked` +- `comfy.desktop.install.method.selected` +- `comfy.desktop.install.not_started` +- `comfy.desktop.install.phase` +- `comfy.desktop.install.showcase.cloud_open` +- `comfy.desktop.install.validation` +- `comfy.desktop.install.variant.selected` + +### ComfyUI lifecycle + +- `comfy.desktop.comfyui.accelerator_detected` +- `comfy.desktop.comfyui.boot_started` +- `comfy.desktop.comfyui.boot_completed` +- `comfy.desktop.comfyui.boot_failed` +- `comfy.desktop.comfyui.boot_log` +- `comfy.desktop.comfyui.boot_phase` +- `comfy.desktop.comfyui.canvas_rendered` +- `comfy.desktop.comfyui.exited` +- `comfy.desktop.comfyui.model_usage_summary` +- `comfy.desktop.comfyui.update.applied` + +### Instances, actions, and navigation + +- `comfy.desktop.action.invoked` +- `comfy.desktop.action.result` +- `comfy.desktop.op.result` +- `comfy.desktop.instance.opened_new_window` +- `comfy.desktop.instance.relaunched_after_crash` +- `comfy.desktop.instance.return_to_dashboard` +- `comfy.desktop.instance.switched` +- `comfy.desktop.view.opened` +- `comfy.desktop.workspace.refresh` +- `comfy.desktop.zoom.reset` +- `comfy.desktop.title_menu.item_clicked` + +### Execution and models + +- `comfy.desktop.execution.error` +- `comfy.desktop.execution.first_completed` +- `comfy.desktop.execution.session_summary` +- `comfy.desktop.model_download.started` +- `comfy.desktop.model_download.result` +- `comfy.desktop.node.installed` + +### Snapshots and migration + +- `comfy.desktop.snapshot.created` +- `comfy.desktop.snapshot.flow` +- `comfy.desktop.snapshot.imported` +- `comfy.desktop.snapshot.shared` +- `comfy.desktop.migrate.restore_snapshot.error` +- `comfy.desktop.recovery.failed` +- `comfy.desktop.recovery.rolled_back` +- `comfy.desktop.track_existing.saved` + +### Templates + +- `comfy.desktop.template.download.skipped` +- `comfy.desktop.template.install_confirmed` +- `comfy.desktop.template.picker_shown` +- `comfy.desktop.template.selected` +- `comfy.desktop.template.skipped` + +### Settings + +- `comfy.desktop.args.builder.opened` +- `comfy.desktop.args.changed` +- `comfy.desktop.settings.changed` + +### Billing and cloud + +- `comfy.desktop.billing.checkout_opened` +- `comfy.desktop.billing.checkout_returned` +- `comfy.desktop.billing.tier_changed` +- `comfy.desktop.cloud.entered` +- `comfy.desktop.dashboard.why_cloud_opened` +- `comfy.desktop.dashboard.why_cloud_action` + +### Feedback and experiments + +- `comfy.desktop.feedback.opened` +- `comfy.desktop.feedback.submitted` +- `comfy.desktop.experiment.exposed` + +### Git and environment repair + +- `comfy.desktop.git.system_fallback` +- `comfy.desktop.pygit2.circuit_broken` +- `comfy.desktop.pygit2.probe_failed` +- `comfy.desktop.pygit2.repair_attempted` +- `comfy.desktop.manager.config_seed_failed` +- `comfy.desktop.torch_repair.detected` +- `comfy.desktop.torch_repair.failed` +- `comfy.desktop.torch_repair.recovery_failed` +- `comfy.desktop.torch_repair.succeeded` + +### Desktop adoption + +- `comfy.desktop.adopt.started` +- `comfy.desktop.adopt.succeeded` + +### MCP + +- `comfy.desktop.mcp.sidebar_opened` +- `comfy.desktop.mcp.docs_opened` +- `comfy.desktop.mcp.option_selected` +- `comfy.desktop.mcp.panel_dismissed` +- `comfy.desktop.mcp.path_selected` +- `comfy.desktop.mcp.snippet_copied` +- `comfy.desktop.mcp.terminal_opened` + +### Telemetry safeguards + +- `comfy.desktop.telemetry.rate_limited` +- `comfy.desktop.telemetry.session_cap_hit` + +## Generated step events + +`trackedStep()` in `src/main/lib/telemetry.ts` generates `.start` and `.end` +events for every base below. Therefore `x.{start,end}` represents two exact +event names: `x.start` and `x.end`. + +### Adoption + +- `comfy.desktop.adopt.allocate.{start,end}` +- `comfy.desktop.adopt.backup.{start,end}` +- `comfy.desktop.adopt.carry_settings.{start,end}` +- `comfy.desktop.adopt.detect.{start,end}` +- `comfy.desktop.adopt.find_existing.{start,end}` +- `comfy.desktop.adopt.register.{start,end}` +- `comfy.desktop.adopt.requirements.{start,end}` +- `comfy.desktop.adopt.requirements_reconcile.{start,end}` +- `comfy.desktop.adopt.snapshot.{start,end}` +- `comfy.desktop.adopt.source.{start,end}` +- `comfy.desktop.adopt.tcc.{start,end}` +- `comfy.desktop.adopt.validate_venv.{start,end}` + +### Installation and migration + +- `comfy.desktop.install.post_install.{start,end}` +- `comfy.desktop.install.standalone.{start,end}` +- `comfy.desktop.migrate.allocate.{start,end}` +- `comfy.desktop.migrate.finalize.{start,end}` +- `comfy.desktop.migrate.flow.{start,end}` +- `comfy.desktop.migrate.input.{start,end}` +- `comfy.desktop.migrate.models.{start,end}` +- `comfy.desktop.migrate.output.{start,end}` +- `comfy.desktop.migrate.prepare_target.{start,end}` +- `comfy.desktop.migrate.resolve_target.{start,end}` +- `comfy.desktop.migrate.restore_snapshot.{start,end}` +- `comfy.desktop.migrate.source_preflight.{start,end}` +- `comfy.desktop.migrate.user_files.{start,end}` + +### Snapshot restoration + +- `comfy.desktop.snapshot.restore_comfyui_version.{start,end}` +- `comfy.desktop.snapshot.restore_custom_nodes.{start,end}` +- `comfy.desktop.snapshot.restore_pip_packages.{start,end}` + +### Reachable generated errors + +Canonical flow scopes suppress duplicate inner errors. The current production +call graph can emit these generated failures: + +- `comfy.desktop.adopt.requirements_reconcile.error` +- `comfy.desktop.migrate.flow.error` +- `comfy.desktop.migrate.restore_snapshot.error` +- `comfy.desktop.snapshot.restore_comfyui_version.error` +- `comfy.desktop.snapshot.restore_custom_nodes.error` +- `comfy.desktop.snapshot.restore_pip_packages.error` + +## Open-ended bridge caveat + +The hosted ComfyUI bridge accepts an arbitrary `event: string` through +`src/types/comfyDesktopBridge.ts`. Code loaded remotely inside ComfyUI can +therefore submit additional names that are not present in this repository. +This inventory is exhaustive for event names defined by this repository, but +cannot enumerate events originating from external hosted code. diff --git a/locales/en.json b/locales/en.json index 8a16ef762..9925965d5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1425,6 +1425,7 @@ "addExistingInstall": "Add Existing Instance", "loadSnapshot": "Load Snapshot", "signIn": "Log in", + "performanceBenchmarks": "Performance Test", "globalSettings": "Desktop Settings", "sendFeedback": "Send Feedback", "returnToDashboard": "Return to Dashboard", @@ -1433,6 +1434,50 @@ "exitAllWindows": "Quit Desktop", "skipOnboarding": "Skip Onboarding" }, + "performanceBenchmarks": { + "description": "Run performance measurements against your ComfyUI instances.", + "selectInstance": "1. Select an instance", + "selectInstancePlaceholder": "Select an instance", + "dropWorkflow": "2. Drop a workflow in API format", + "dropWorkflowHint": "Drop a workflow .json file here, or click to browse", + "importingWorkflow": "Importing workflow...", + "importFailed": "Could not import the workflow.", + "deleteWorkflow": "Delete workflow", + "deleteFailed": "Could not delete the workflow.", + "run": "Run", + "stop": "Stop", + "stopping": "Stopping...", + "stopFailed": "Could not stop the instance.", + "measurementSettings": "3. Set measurements settings", + "measuredRuns": "Measured runs", + "logsPlaceholder": "Instance logs will appear here.", + "results": "Results", + "resultsPlaceholder": "Benchmark results will appear here.", + "fastestJob": "Fastest job", + "slowestJob": "Slowest job", + "averageDuration": "Average job duration", + "medianDuration": "Median job duration", + "hardware": "Hardware", + "device": "Compute device", + "vram": "VRAM", + "ram": "RAM", + "pytorch": "PyTorch", + "xformers": "xFormers", + "systemInformation": "System information", + "operatingSystem": "Operating system", + "architecture": "Architecture", + "cpu": "CPU", + "logicalCpuCores": "Logical CPU cores", + "physicalCpuCores": "Physical CPU cores", + "systemMemory": "System memory", + "systemGpu": "System GPU", + "gpuDriver": "GPU driver", + "starting": "Starting...", + "launchFailed": "Failed to start the instance.", + "submittingRuns": "Submitting {preparationCount} preparation runs and {count} measured runs...", + "completedRuns": "Finished {count} measured runs ({unsuccessful} unsuccessful). Final response saved to {path}", + "submitFailed": "Failed to submit the benchmark workflow." + }, "downloadsPopup": { "title": "Downloads", "empty": "No downloads yet", diff --git a/locales/zh.json b/locales/zh.json index d97cd60e2..b305dda82 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -1425,6 +1425,7 @@ "addExistingInstall": "添加现有实例", "loadSnapshot": "加载快照", "signIn": "登录", + "performanceBenchmarks": "性能测试", "globalSettings": "桌面端设置", "sendFeedback": "发送反馈", "returnToDashboard": "返回仪表板", @@ -1433,6 +1434,50 @@ "exitAllWindows": "退出桌面端", "skipOnboarding": "跳过引导" }, + "performanceBenchmarks": { + "description": "针对您的 ComfyUI 实例运行性能测量。", + "selectInstance": "1. 选择实例", + "selectInstancePlaceholder": "选择实例", + "dropWorkflow": "2. 拖放 API 格式的工作流", + "dropWorkflowHint": "将工作流 .json 文件拖放到此处,或点击浏览", + "importingWorkflow": "正在导入工作流...", + "importFailed": "无法导入工作流。", + "deleteWorkflow": "删除工作流", + "deleteFailed": "无法删除工作流。", + "run": "运行", + "stop": "停止", + "stopping": "正在停止...", + "stopFailed": "无法停止实例。", + "measurementSettings": "3. 设置测量参数", + "measuredRuns": "测量运行次数", + "logsPlaceholder": "实例日志将显示在此处。", + "results": "结果", + "resultsPlaceholder": "基准测试结果将显示在此处。", + "fastestJob": "最快任务", + "slowestJob": "最慢任务", + "averageDuration": "平均任务时长", + "medianDuration": "任务时长中位数", + "hardware": "硬件", + "device": "计算设备", + "vram": "显存", + "ram": "内存", + "pytorch": "PyTorch", + "xformers": "xFormers", + "systemInformation": "系统信息", + "operatingSystem": "操作系统", + "architecture": "架构", + "cpu": "CPU", + "logicalCpuCores": "逻辑 CPU 核心", + "physicalCpuCores": "物理 CPU 核心", + "systemMemory": "系统内存", + "systemGpu": "系统 GPU", + "gpuDriver": "GPU 驱动", + "starting": "正在启动...", + "launchFailed": "无法启动实例。", + "submittingRuns": "正在提交 {preparationCount} 次准备运行和 {count} 次测量运行...", + "completedRuns": "已完成 {count} 次测量运行({unsuccessful} 次未成功)。最终响应已保存到 {path}", + "submitFailed": "无法提交基准工作流。" + }, "downloadsPopup": { "title": "下载", "empty": "还没有下载", diff --git a/src/main/host/panelView.ts b/src/main/host/panelView.ts index e8f016e01..6d21fe03c 100644 --- a/src/main/host/panelView.ts +++ b/src/main/host/panelView.ts @@ -33,7 +33,12 @@ function opaquePanelBg(): string { * an opaque surface instead of black. Overlay modes (downloads / feedback) * deliberately stay transparent. */ function isOpaqueBodyMode(mode: BodyMode): boolean { - return mode === 'chooser' || mode === 'new-install' || mode === 'comfy-lifecycle' + return ( + mode === 'chooser' || + mode === 'performance-benchmarks' || + mode === 'new-install' || + mode === 'comfy-lifecycle' + ) } /** diff --git a/src/main/host/registry.ts b/src/main/host/registry.ts index 5e2bea47c..b089f79ff 100644 --- a/src/main/host/registry.ts +++ b/src/main/host/registry.ts @@ -19,6 +19,7 @@ export const hostInstallEvents = new EventEmitter() */ export type ComfyPanelKey = | 'comfy' + | 'performance-benchmarks' | 'feedback' | 'new-install' | 'track' @@ -37,6 +38,7 @@ export type ComfyPanelKey = export const VALID_PANELS: ReadonlySet = new Set([ 'comfy', + 'performance-benchmarks', 'feedback', 'new-install', 'track', @@ -57,6 +59,7 @@ export type BodyMode = | 'comfy-lifecycle' | 'feedback' | 'chooser' + | 'performance-benchmarks' /** Mirror of the `'progress'` ComfyPanelKey; forces the panel to fully cover * the canvas while a picker-driven ProgressModal is mounted. */ | 'progress' diff --git a/src/main/lib/benchmarkWorkflows.test.ts b/src/main/lib/benchmarkWorkflows.test.ts new file mode 100644 index 000000000..9387393d5 --- /dev/null +++ b/src/main/lib/benchmarkWorkflows.test.ts @@ -0,0 +1,387 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + calculateBenchmarkStatistics, + deleteBenchmarkWorkflow, + incrementWorkflowSeeds, + saveBenchmarkAggregates, + saveBenchmarkJobsResponse, + storeBenchmarkWorkflow, + submitBenchmarkWorkflow, + waitForBenchmarkJobs +} from './benchmarkWorkflows' + +describe('calculateBenchmarkStatistics', () => { + it('calculates fastest, slowest, average, and median for measured jobs only', () => { + const response = { + jobs: [ + { + id: 'measured-3', + status: 'completed', + execution_start_time: 30000, + execution_end_time: 39000 + }, + { + id: 'warmup', + status: 'completed', + execution_start_time: 0, + execution_end_time: 100000 + }, + { + id: 'measured-1', + status: 'completed', + execution_start_time: 10000, + execution_end_time: 12000 + }, + { + id: 'measured-4', + status: 'completed', + execution_start_time: 40000, + execution_end_time: 44000 + }, + { + id: 'measured-2', + status: 'completed', + execution_start_time: 20000, + execution_end_time: 26000 + } + ] + } + + expect( + calculateBenchmarkStatistics(response, [ + 'measured-1', + 'measured-2', + 'measured-3', + 'measured-4' + ]) + ).toEqual({ + fastest: { jobId: 'measured-1', durationSeconds: 2 }, + slowest: { jobId: 'measured-3', durationSeconds: 9 }, + averageDurationSeconds: 5.25, + medianDurationSeconds: 5, + measuredJobCount: 4 + }) + }) + + it('ignores jobs without valid timestamps and returns null when none are measurable', () => { + expect( + calculateBenchmarkStatistics( + { + jobs: [ + { id: 'missing-end', status: 'failed', execution_start_time: 10 }, + { + id: 'backwards', + status: 'completed', + execution_start_time: 10, + execution_end_time: 5 + } + ] + }, + ['missing-end', 'backwards'] + ) + ).toBeNull() + }) +}) + +const tempDirs: string[] = [] + +async function makeTempDir(): Promise { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'comfy-benchmark-workflow-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => + fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 20 }) + ) + ) +}) + +describe('storeBenchmarkWorkflow', () => { + it('copies an API-format workflow into the app user-data directory', async () => { + const root = await makeTempDir() + const sourcePath = path.join(root, 'benchmark.json') + const contents = JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + await fs.promises.writeFile(sourcePath, contents) + + const storedPath = await storeBenchmarkWorkflow(sourcePath, path.join(root, 'user-data')) + + expect(path.dirname(path.dirname(storedPath))).toBe(path.join(root, 'user-data', 'benchmarks')) + expect(path.basename(path.dirname(storedPath))).toMatch(/^\d{14}$/) + expect(path.basename(storedPath)).toBe('benchmark.json') + expect(await fs.promises.readFile(storedPath, 'utf8')).toBe(contents) + expect(await fs.promises.readFile(sourcePath, 'utf8')).toBe(contents) + }) + + it('creates a unique timestamped session directory for each workflow', async () => { + const root = await makeTempDir() + const sourcePath = path.join(root, 'benchmark.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + + const firstPath = await storeBenchmarkWorkflow(sourcePath, path.join(root, 'user-data')) + const secondPath = await storeBenchmarkWorkflow(sourcePath, path.join(root, 'user-data')) + + expect(path.basename(secondPath)).toBe('benchmark.json') + expect(path.basename(path.dirname(secondPath))).toMatch(/^\d{14}$/) + expect(path.dirname(secondPath)).not.toBe(path.dirname(firstPath)) + }) + + it.each(['results.json', 'aggregates.json'])('reserves %s for benchmark output', async (name) => { + const root = await makeTempDir() + const sourcePath = path.join(root, name) + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + + await expect(storeBenchmarkWorkflow(sourcePath, path.join(root, 'user-data'))).rejects.toThrow( + `${name} is reserved` + ) + }) + + it('rejects JSON that is not a ComfyUI API-format workflow', async () => { + const root = await makeTempDir() + const sourcePath = path.join(root, 'editor-workflow.json') + await fs.promises.writeFile(sourcePath, JSON.stringify({ nodes: [], links: [] })) + + await expect(storeBenchmarkWorkflow(sourcePath, path.join(root, 'user-data'))).rejects.toThrow( + 'not a ComfyUI API-format workflow' + ) + }) +}) + +describe('deleteBenchmarkWorkflow', () => { + it('deletes a managed benchmark workflow copy', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'benchmark.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storeBenchmarkWorkflow(sourcePath, userDataPath) + + await deleteBenchmarkWorkflow(storedPath, userDataPath) + + await expect(fs.promises.stat(storedPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('refuses to delete files outside the managed directory', async () => { + const root = await makeTempDir() + const sourcePath = path.join(root, 'keep.json') + await fs.promises.writeFile(sourcePath, '{}') + + await expect(deleteBenchmarkWorkflow(sourcePath, path.join(root, 'user-data'))).rejects.toThrow( + 'outside a managed benchmark session directory' + ) + expect(await fs.promises.readFile(sourcePath, 'utf8')).toBe('{}') + }) + + it('preserves a completed session when clearing its workflow from the page', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'benchmark.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storeBenchmarkWorkflow(sourcePath, userDataPath) + const resultsPath = path.join(path.dirname(storedPath), 'results.json') + await fs.promises.writeFile(resultsPath, '{}') + + await deleteBenchmarkWorkflow(storedPath, userDataPath) + + await expect(fs.promises.stat(storedPath)).resolves.toBeDefined() + await expect(fs.promises.stat(resultsPath)).resolves.toBeDefined() + }) +}) + +describe('submitBenchmarkWorkflow', () => { + it('posts model-load and warm-up requests before the measured runs with incremented seeds', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'benchmark.json') + const workflow = { '1': { class_type: 'KSampler', inputs: { seed: 1 } } } + await fs.promises.writeFile(sourcePath, JSON.stringify(workflow)) + const storedPath = await storeBenchmarkWorkflow(sourcePath, userDataPath) + let requestCount = 0 + const fetchMock = vi.fn(async () => { + requestCount++ + return new Response(JSON.stringify({ prompt_id: `prompt-${requestCount}` })) + }) + + const promptIds = await submitBenchmarkWorkflow( + storedPath, + userDataPath, + 'http://127.0.0.1:8189/base', + 3, + fetchMock + ) + + expect(promptIds).toEqual(['prompt-1', 'prompt-2', 'prompt-3', 'prompt-4', 'prompt-5']) + expect(fetchMock).toHaveBeenCalledTimes(5) + for (const [index, [requestUrl, requestInit]] of fetchMock.mock.calls.entries()) { + expect(String(requestUrl)).toBe('http://127.0.0.1:8189/prompt') + expect(requestInit).toMatchObject({ + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + expect(JSON.parse(String(requestInit?.body))).toEqual({ + prompt: { '1': { class_type: 'KSampler', inputs: { seed: index + 2 } } } + }) + } + expect(JSON.parse(await fs.promises.readFile(storedPath, 'utf8'))).toEqual(workflow) + }) + + it('stops submitting when ComfyUI rejects a request', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'benchmark.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storeBenchmarkWorkflow(sourcePath, userDataPath) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ prompt_id: 'prompt-1' }))) + .mockResolvedValueOnce(new Response('invalid workflow', { status: 400 })) + + await expect( + submitBenchmarkWorkflow(storedPath, userDataPath, 'http://127.0.0.1:8189', 3, fetchMock) + ).rejects.toThrow('Benchmark request 2 failed: 400') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('rejects a successful response without a prompt ID', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'benchmark.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storeBenchmarkWorkflow(sourcePath, userDataPath) + const fetchMock = vi.fn(async () => new Response('{}')) + + await expect( + submitBenchmarkWorkflow(storedPath, userDataPath, 'http://127.0.0.1:8189', 1, fetchMock) + ).rejects.toThrow('did not return a prompt ID') + }) +}) + +describe('waitForBenchmarkJobs', () => { + it('polls the jobs collection until every submitted prompt is terminal', async () => { + const pendingResponse = { + jobs: [ + { id: 'prompt-1', status: 'completed' }, + { id: 'prompt-2', status: 'in_progress' }, + { id: 'unrelated', status: 'pending' } + ] + } + const terminalResponse = { + jobs: [ + { id: 'prompt-1', status: 'completed' }, + { id: 'prompt-2', status: 'failed', execution_error: { message: 'failed' } } + ], + pagination: { total: 2, has_more: false } + } + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(pendingResponse))) + .mockResolvedValueOnce(new Response(JSON.stringify(terminalResponse))) + + await expect( + waitForBenchmarkJobs('http://127.0.0.1:8189/base', ['prompt-1', 'prompt-2'], fetchMock, 0) + ).resolves.toEqual(terminalResponse) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(String(fetchMock.mock.calls[0]![0])).toBe('http://127.0.0.1:8189/api/jobs?limit=2') + }) +}) + +describe('saveBenchmarkJobsResponse', () => { + it('writes the final jobs response beside the session workflow', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'benchmark.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storeBenchmarkWorkflow(sourcePath, userDataPath) + const response = { jobs: [{ id: 'prompt-1', status: 'completed' }] } + + const resultPath = await saveBenchmarkJobsResponse(response, storedPath, userDataPath) + + expect(resultPath).toBe(path.join(path.dirname(storedPath), 'results.json')) + expect(JSON.parse(await fs.promises.readFile(resultPath, 'utf8'))).toEqual(response) + }) +}) + +describe('saveBenchmarkAggregates', () => { + it('writes duration aggregates beside the workflow and raw results', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'benchmark.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storeBenchmarkWorkflow(sourcePath, userDataPath) + + const aggregatesPath = await saveBenchmarkAggregates( + { + fastest: { jobId: 'job-1', durationSeconds: 1.25 }, + slowest: { jobId: 'job-2', durationSeconds: 2.75 }, + averageDurationSeconds: 2, + medianDurationSeconds: 1.875, + measuredJobCount: 2 + }, + storedPath, + userDataPath + ) + + expect(aggregatesPath).toBe(path.join(path.dirname(storedPath), 'aggregates.json')) + expect(JSON.parse(await fs.promises.readFile(aggregatesPath, 'utf8'))).toEqual({ + fastestJobDurationSeconds: 1.25, + slowestJobDurationSeconds: 2.75, + averageJobDurationSeconds: 2, + medianJobDurationSeconds: 1.875, + measuredJobCount: 2 + }) + }) +}) + +describe('incrementWorkflowSeeds', () => { + it('increments all numeric seed-like inputs without mutating the source workflow', () => { + const workflow = { + sampler: { + class_type: 'KSampler', + inputs: { seed: 10, noise_seed: 20, seed_mode: 'fixed', cfg: 7 } + }, + linked: { class_type: 'Sampler', inputs: { seed: ['primitive', 0] } } + } + + expect(incrementWorkflowSeeds(workflow)).toEqual({ + sampler: { + class_type: 'KSampler', + inputs: { seed: 11, noise_seed: 21, seed_mode: 'fixed', cfg: 7 } + }, + linked: { class_type: 'Sampler', inputs: { seed: ['primitive', 0] } } + }) + expect(workflow.sampler.inputs.seed).toBe(10) + expect(workflow.sampler.inputs.noise_seed).toBe(20) + }) +}) diff --git a/src/main/lib/benchmarkWorkflows.ts b/src/main/lib/benchmarkWorkflows.ts new file mode 100644 index 000000000..2c94f263b --- /dev/null +++ b/src/main/lib/benchmarkWorkflows.ts @@ -0,0 +1,351 @@ +import fs from 'fs' +import path from 'path' + +const BENCHMARKS_DIR = 'benchmarks' +const BENCHMARK_POLL_INTERVAL_MS = 1000 +export const BENCHMARK_PREPARATION_RUNS = 2 + +const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'cancelled']) + +export interface BenchmarkJob { + id: string + status: string + [key: string]: unknown +} + +export interface BenchmarkJobsResponse { + jobs: BenchmarkJob[] + pagination?: unknown + [key: string]: unknown +} + +export interface BenchmarkDurationResult { + jobId: string + durationSeconds: number +} + +export interface BenchmarkStatistics { + fastest: BenchmarkDurationResult + slowest: BenchmarkDurationResult + averageDurationSeconds: number + medianDurationSeconds: number + measuredJobCount: number +} + +export interface BenchmarkAggregates { + fastestJobDurationSeconds: number | null + slowestJobDurationSeconds: number | null + averageJobDurationSeconds: number | null + medianJobDurationSeconds: number | null + measuredJobCount: number +} + +/** Calculate duration statistics for measured jobs with valid start and end timestamps. */ +export function calculateBenchmarkStatistics( + response: BenchmarkJobsResponse, + measuredPromptIds: string[] +): BenchmarkStatistics | null { + const measuredIds = new Set(measuredPromptIds) + const durations = response.jobs.flatMap((job) => { + if (!measuredIds.has(job.id)) return [] + const start = job.execution_start_time + const end = job.execution_end_time + if ( + typeof start !== 'number' || + !Number.isFinite(start) || + typeof end !== 'number' || + !Number.isFinite(end) || + end < start + ) { + return [] + } + return [{ jobId: job.id, durationSeconds: (end - start) / 1000 }] + }) + if (durations.length === 0) return null + + const sorted = [...durations].sort((a, b) => a.durationSeconds - b.durationSeconds) + const middle = Math.floor(sorted.length / 2) + const medianDurationSeconds = + sorted.length % 2 === 0 + ? (sorted[middle - 1]!.durationSeconds + sorted[middle]!.durationSeconds) / 2 + : sorted[middle]!.durationSeconds + return { + fastest: sorted[0]!, + slowest: sorted[sorted.length - 1]!, + averageDurationSeconds: + durations.reduce((sum, result) => sum + result.durationSeconds, 0) / durations.length, + medianDurationSeconds, + measuredJobCount: durations.length + } +} + +function isApiWorkflow(value: unknown): value is object { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const nodes = Object.values(value) + return ( + nodes.length > 0 && + nodes.every( + (node) => + node !== null && + typeof node === 'object' && + typeof (node as { class_type?: unknown }).class_type === 'string' && + (node as { inputs?: unknown }).inputs !== null && + typeof (node as { inputs?: unknown }).inputs === 'object' && + !Array.isArray((node as { inputs?: unknown }).inputs) + ) + ) +} + +/** Return a workflow copy with every numeric seed input advanced by one. */ +export function incrementWorkflowSeeds(workflow: object): object { + const nextWorkflow = structuredClone(workflow) as Record + for (const node of Object.values(nextWorkflow)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) continue + const inputs = (node as { inputs?: unknown }).inputs + if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) continue + const mutableInputs = inputs as Record + for (const [name, value] of Object.entries(inputs)) { + if ( + name.toLowerCase().includes('seed') && + typeof value === 'number' && + Number.isFinite(value) + ) { + mutableInputs[name] = value + 1 + } + } + } + return nextWorkflow +} + +function formatBenchmarkSessionId(date: Date): string { + return [ + date.getFullYear(), + date.getMonth() + 1, + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds() + ] + .map((part) => String(part).padStart(2, '0')) + .join('') +} + +function resolveManagedWorkflowPath( + filePath: string, + userDataPath: string +): { filePath: string; sessionDir: string } { + const benchmarksDir = path.resolve(userDataPath, BENCHMARKS_DIR) + const resolvedPath = path.resolve(filePath) + const relativePath = path.relative(benchmarksDir, resolvedPath) + const parts = relativePath.split(path.sep) + if ( + parts.length !== 2 || + !/^\d{14}$/.test(parts[0]!) || + path.isAbsolute(relativePath) || + parts.includes('..') + ) { + throw new Error('The workflow is outside a managed benchmark session directory.') + } + return { filePath: resolvedPath, sessionDir: path.dirname(resolvedPath) } +} + +async function readBenchmarkWorkflow(filePath: string, userDataPath: string): Promise { + const managedPath = resolveManagedWorkflowPath(filePath, userDataPath).filePath + const contents = await fs.promises.readFile(managedPath, 'utf8') + let parsed: unknown + try { + parsed = JSON.parse(contents) + } catch { + throw new Error('The benchmark workflow is not valid JSON.') + } + if (!isApiWorkflow(parsed)) { + throw new Error('The benchmark workflow is not a ComfyUI API-format workflow.') + } + return parsed +} + +/** Validate and persist a user-selected API workflow outside any installation. */ +export async function storeBenchmarkWorkflow( + sourcePath: string, + userDataPath: string +): Promise { + if (path.extname(sourcePath).toLowerCase() !== '.json') { + throw new Error('Select a .json workflow file.') + } + const sourceFileName = path.basename(sourcePath) + if (['results.json', 'aggregates.json'].includes(sourceFileName.toLowerCase())) { + throw new Error(`The workflow filename ${sourceFileName} is reserved for benchmark output.`) + } + + const contents = await fs.promises.readFile(sourcePath) + let parsed: unknown + try { + parsed = JSON.parse(contents.toString('utf8')) + } catch { + throw new Error('The selected file is not valid JSON.') + } + if (!isApiWorkflow(parsed)) { + throw new Error('The selected file is not a ComfyUI API-format workflow.') + } + + const benchmarksDir = path.join(userDataPath, BENCHMARKS_DIR) + await fs.promises.mkdir(benchmarksDir, { recursive: true }) + + for (let offsetSeconds = 0; ; offsetSeconds++) { + const sessionId = formatBenchmarkSessionId(new Date(Date.now() + offsetSeconds * 1000)) + const sessionDir = path.join(benchmarksDir, sessionId) + try { + await fs.promises.mkdir(sessionDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue + throw error + } + + const destinationPath = path.join(sessionDir, sourceFileName) + try { + await fs.promises.writeFile(destinationPath, contents) + return destinationPath + } catch (error) { + await fs.promises.rm(sessionDir, { recursive: true, force: true }) + throw error + } + } +} + +/** Delete a workflow copy managed by the benchmark page. */ +export async function deleteBenchmarkWorkflow( + filePath: string, + userDataPath: string +): Promise { + const managedPath = resolveManagedWorkflowPath(filePath, userDataPath) + try { + await fs.promises.access(path.join(managedPath.sessionDir, 'results.json')) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + await fs.promises.unlink(managedPath.filePath) + await fs.promises.rmdir(managedPath.sessionDir).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOTEMPTY') throw error + }) +} + +/** Queue model-load and warm-up requests, followed by each measured run. */ +export async function submitBenchmarkWorkflow( + filePath: string, + userDataPath: string, + sessionUrl: string, + measuredRuns: number, + fetchImpl: typeof fetch = fetch +): Promise { + if (!Number.isInteger(measuredRuns) || measuredRuns < 1 || measuredRuns > 100) { + throw new Error('Measured runs must be an integer between 1 and 100.') + } + + let workflow = await readBenchmarkWorkflow(filePath, userDataPath) + const endpoint = new URL('/prompt', sessionUrl) + const promptIds: string[] = [] + const totalRuns = measuredRuns + BENCHMARK_PREPARATION_RUNS + + for (let run = 1; run <= totalRuns; run++) { + workflow = incrementWorkflowSeeds(workflow) + const response = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: workflow }) + }) + if (!response.ok) { + const detail = (await response.text()).trim() + throw new Error( + `Benchmark request ${run} failed: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}` + ) + } + const result = (await response.json()) as { prompt_id?: unknown; error?: unknown } + if (result.error) { + throw new Error(`Benchmark request ${run} failed: ${String(result.error)}`) + } + if (typeof result.prompt_id !== 'string') { + throw new Error(`Benchmark request ${run} did not return a prompt ID.`) + } + promptIds.push(result.prompt_id) + } + + return promptIds +} + +/** Poll the jobs collection until every submitted prompt reaches a terminal state. */ +export async function waitForBenchmarkJobs( + sessionUrl: string, + promptIds: string[], + fetchImpl: typeof fetch = fetch, + pollIntervalMs = BENCHMARK_POLL_INTERVAL_MS +): Promise { + const endpoint = new URL('/api/jobs', sessionUrl) + endpoint.searchParams.set('limit', String(promptIds.length)) + const expectedPromptIds = new Set(promptIds) + + for (;;) { + const response = await fetchImpl(endpoint) + if (!response.ok) { + const detail = (await response.text()).trim() + throw new Error( + `Could not check benchmark jobs: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}` + ) + } + + const result = (await response.json()) as Partial + if (!Array.isArray(result.jobs)) { + throw new Error('The ComfyUI jobs response did not contain a jobs array.') + } + + const statuses = new Map( + result.jobs + .filter( + (job): job is BenchmarkJob => + job !== null && + typeof job === 'object' && + typeof job.id === 'string' && + typeof job.status === 'string' + ) + .map((job) => [job.id, job.status]) + ) + const allTerminal = [...expectedPromptIds].every((id) => { + const status = statuses.get(id) + return status !== undefined && TERMINAL_JOB_STATUSES.has(status) + }) + if (allTerminal) return result as BenchmarkJobsResponse + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } +} + +/** Persist the final jobs API response and return its absolute path. */ +export async function saveBenchmarkJobsResponse( + response: BenchmarkJobsResponse, + workflowFilePath: string, + userDataPath: string +): Promise { + const { sessionDir } = resolveManagedWorkflowPath(workflowFilePath, userDataPath) + const resultPath = path.join(sessionDir, 'results.json') + await fs.promises.writeFile(resultPath, `${JSON.stringify(response, null, 2)}\n`, 'utf8') + return resultPath +} + +/** Persist measured duration aggregates beside the workflow and raw jobs response. */ +export async function saveBenchmarkAggregates( + statistics: BenchmarkStatistics | null, + workflowFilePath: string, + userDataPath: string +): Promise { + const { sessionDir } = resolveManagedWorkflowPath(workflowFilePath, userDataPath) + const aggregates: BenchmarkAggregates = { + fastestJobDurationSeconds: statistics?.fastest.durationSeconds ?? null, + slowestJobDurationSeconds: statistics?.slowest.durationSeconds ?? null, + averageJobDurationSeconds: statistics?.averageDurationSeconds ?? null, + medianJobDurationSeconds: statistics?.medianDurationSeconds ?? null, + measuredJobCount: statistics?.measuredJobCount ?? 0 + } + const aggregatesPath = path.join(sessionDir, 'aggregates.json') + await fs.promises.writeFile(aggregatesPath, `${JSON.stringify(aggregates, null, 2)}\n`, 'utf8') + return aggregatesPath +} diff --git a/src/main/lib/hardwareTap.test.ts b/src/main/lib/hardwareTap.test.ts index 3f184b625..934e8e7cd 100644 --- a/src/main/lib/hardwareTap.test.ts +++ b/src/main/lib/hardwareTap.test.ts @@ -285,6 +285,33 @@ describe('createHardwareTap', () => { }) }) + it('exposes the same accelerator details parsed for telemetry', () => { + const tap = createHardwareTap({ installationId: 'inst-1' }) + tap.ingest('Total VRAM 24576 MB, total RAM 65461 MB\n', 'stdout') + tap.ingest('pytorch version: 2.10.0+cu130\n', 'stdout') + tap.ingest('Device: cuda:0 NVIDIA GeForce RTX 4090 : native\n', 'stdout') + + expect(tap.getAcceleratorInfo()).toEqual({ + deviceType: 'cuda', + deviceIndex: 0, + deviceName: 'NVIDIA GeForce RTX 4090', + backend: 'native', + devices: [ + { + deviceType: 'cuda', + deviceIndex: 0, + deviceName: 'NVIDIA GeForce RTX 4090', + backend: 'native' + } + ], + vramMb: 24576, + ramMb: 65461, + pytorchVersion: '2.10.0+cu130', + xformersVersion: null, + cudaDeviceSet: null + }) + }) + it('does not promote a cpu device to gpu person properties', () => { const tap = createHardwareTap({ installationId: 'inst-1' }) tap.ingest('Device: cpu\n', 'stdout') diff --git a/src/main/lib/hardwareTap.ts b/src/main/lib/hardwareTap.ts index 490c3e834..12fea290b 100644 --- a/src/main/lib/hardwareTap.ts +++ b/src/main/lib/hardwareTap.ts @@ -44,6 +44,15 @@ export interface AcceleratorInfo { backend: string | null } +export interface AcceleratorSnapshot extends AcceleratorInfo { + devices: AcceleratorInfo[] + vramMb: number | null + ramMb: number | null + pytorchVersion: string | null + xformersVersion: string | null + cudaDeviceSet: number | null +} + const DEVICE_LINE = /^Device:\s*(.+)$/ const VRAM_LINE = /^Total VRAM\s+(\d+)\s*MB,\s*total RAM\s+(\d+)\s*MB/i const PYTORCH_LINE = /^pytorch version:\s*(.+)$/i @@ -111,6 +120,7 @@ export function createHardwareTap(opts: { }): { ingest: (chunk: string, source: 'stdout' | 'stderr') => void beginBoot: () => void + getAcceleratorInfo: () => AcceleratorSnapshot | null flushSummary: () => void } { const baseContext = { @@ -205,6 +215,27 @@ export function createHardwareTap(opts: { } } + function getAcceleratorInfo(): AcceleratorSnapshot | null { + if (devices.length === 0) return null + const primary = devices[0]! + const primaryName = + primary.deviceName ?? + (primary.deviceType !== 'cpu' && primary.deviceType !== 'mps' ? directmlDeviceName : null) + return { + ...primary, + deviceName: primaryName, + devices: devices.map((device, index) => ({ + ...device, + deviceName: index === 0 ? primaryName : device.deviceName + })), + vramMb, + ramMb, + pytorchVersion, + xformersVersion, + cudaDeviceSet + } + } + function handleLine(line: string): void { // Strip a leading `[LEVEL] ` tag (ComfyUI Desktop's bundled build) so the // anchored parsers below match both the prefixed and bare log formats. @@ -304,6 +335,7 @@ export function createHardwareTap(opts: { pendingBySource.stdout = '' pendingBySource.stderr = '' }, + getAcceleratorInfo, flushSummary(): void { try { // Process complete-but-unterminated final lines so a trailing `Device:` diff --git a/src/main/lib/ipc/registerAppHandlers.ts b/src/main/lib/ipc/registerAppHandlers.ts index 0ee4231bd..cc740eeec 100644 --- a/src/main/lib/ipc/registerAppHandlers.ts +++ b/src/main/lib/ipc/registerAppHandlers.ts @@ -3,6 +3,7 @@ import { dialog, shell, BrowserWindow, + app, fs, path, os, @@ -25,7 +26,8 @@ import { openPath, listSnapshots, diffSnapshots, - buildInstallationDdContext + buildInstallationDdContext, + _runningSessions } from './shared' import si from 'systeminformation' import type { FieldOption } from './shared' @@ -35,6 +37,16 @@ import { getCloudFreeRunsEnabledAsync } from '../cloudFreeRuns' import { getUserTierAsync } from '../userTier' import { getStableTags } from '../comfyui-releases' import { deriveGpuTier } from '../../../shared/gpuTier' +import { + BENCHMARK_PREPARATION_RUNS, + calculateBenchmarkStatistics, + deleteBenchmarkWorkflow, + saveBenchmarkAggregates, + saveBenchmarkJobsResponse, + storeBenchmarkWorkflow, + submitBenchmarkWorkflow, + waitForBenchmarkJobs +} from '../benchmarkWorkflows' export function registerAppHandlers(): void { // App version @@ -137,6 +149,94 @@ export function registerAppHandlers(): void { return filePaths[0] }) + ipcMain.handle('import-benchmark-workflow', async (_event, droppedFilePath?: string) => { + let sourcePath = typeof droppedFilePath === 'string' ? droppedFilePath : '' + if (!sourcePath) { + const win = BrowserWindow.fromWebContents(_event.sender) + if (!win) return { ok: false, message: 'No window.' } + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + filters: [{ name: 'JSON', extensions: ['json'] }], + properties: ['openFile'] + }) + if (canceled || filePaths.length === 0) return { ok: false, canceled: true } + sourcePath = filePaths[0]! + } + try { + const filePath = await storeBenchmarkWorkflow(sourcePath, app.getPath('userData')) + return { ok: true, filePath } + } catch (error) { + return { ok: false, message: (error as Error)?.message || String(error) } + } + }) + + ipcMain.handle('delete-benchmark-workflow', async (_event, filePath: string) => { + try { + await deleteBenchmarkWorkflow(filePath, app.getPath('userData')) + return { ok: true } + } catch (error) { + return { ok: false, message: (error as Error)?.message || String(error) } + } + }) + + ipcMain.handle( + 'run-benchmark-workflow', + async (_event, sessionId: string, filePath: string, measuredRuns: number) => { + try { + if (typeof sessionId !== 'string' || !sessionId.startsWith('benchmark:')) { + throw new Error('Invalid benchmark session.') + } + const session = _runningSessions.get(sessionId) + if (!session) throw new Error('The benchmark instance is not running.') + const sessionUrl = session.url || `http://127.0.0.1:${session.port}` + const promptIds = await submitBenchmarkWorkflow( + filePath, + app.getPath('userData'), + sessionUrl, + measuredRuns + ) + const measuredPromptIds = promptIds.slice(BENCHMARK_PREPARATION_RUNS) + const jobsResponse = await waitForBenchmarkJobs(sessionUrl, measuredPromptIds) + const statistics = calculateBenchmarkStatistics(jobsResponse, measuredPromptIds) + const resultPath = await saveBenchmarkJobsResponse( + jobsResponse, + filePath, + app.getPath('userData') + ) + const aggregatesPath = await saveBenchmarkAggregates( + statistics, + filePath, + app.getPath('userData') + ) + const systemInfo = await getSystemInfo() + const submittedPromptIds = new Set(measuredPromptIds) + const unsuccessfulJobs = jobsResponse.jobs.filter( + (job) => submittedPromptIds.has(job.id) && job.status !== 'completed' + ) + return { + ok: true, + submitted: measuredRuns, + preparationRuns: BENCHMARK_PREPARATION_RUNS, + totalSubmitted: measuredRuns + BENCHMARK_PREPARATION_RUNS, + promptIds, + resultPath, + aggregatesPath, + statistics, + hardware: session.getAcceleratorInfo?.() ?? null, + systemInfo, + unsuccessfulJobs: unsuccessfulJobs.length + } + } catch (error) { + return { + ok: false, + submitted: 0, + preparationRuns: 0, + totalSubmitted: 0, + message: (error as Error)?.message || String(error) + } + } + } + ) + ipcMain.handle('open-path', (_event, targetPath: string) => { if (typeof targetPath !== 'string' || !targetPath) return '' if (/^https?:\/\//i.test(targetPath)) return shell.openExternal(targetPath) @@ -196,7 +296,7 @@ export function registerAppHandlers(): void { return hardwareProbe } - ipcMain.handle('get-system-info', async () => { + async function getSystemInfo() { const hardware = await probeHardwareCached() const cpus = os.cpus() const allInstalls = await installations.list() @@ -225,7 +325,9 @@ export function registerAppHandlers(): void { status: (inst.status as string) || 'ready' })) } - }) + } + + ipcMain.handle('get-system-info', getSystemInfo) async function probeHardware(): Promise> { const gpu = await detectGPUCached() diff --git a/src/main/lib/ipc/registerSessionHandlers.ts b/src/main/lib/ipc/registerSessionHandlers.ts index a5703ff74..077d65e00 100644 --- a/src/main/lib/ipc/registerSessionHandlers.ts +++ b/src/main/lib/ipc/registerSessionHandlers.ts @@ -8,10 +8,10 @@ import { REQUIRES_STOPPED, _onStop, _operationAborts, - _runningSessions, _getPublicSessions, _getLaunchingInstances, _getStoppingInstallationIds, + hasRunningSessionForInstallation, stopRunning } from './shared' import { dispatchSessionAction, _getActiveOperations } from './sessionActions' @@ -72,7 +72,7 @@ export function registerSessionHandlers(): void { const maybeInst = await installations.get(installationId) if (!maybeInst) return { ok: false, message: 'Installation not found.' } const inst = maybeInst - if (REQUIRES_STOPPED.has(actionId) && _runningSessions.has(installationId)) { + if (REQUIRES_STOPPED.has(actionId) && hasRunningSessionForInstallation(installationId)) { return { ok: false, message: i18n.t('errors.stopRequired'), running: true } } if (REQUIRES_STOPPED.has(actionId) && _operationAborts.has(installationId)) { @@ -84,7 +84,15 @@ export function registerSessionHandlers(): void { return { ok: false, message: i18n.t('errors.operationInProgress', { operation }) } } - return dispatchSessionAction({ event: _event, installationId, inst, actionData }, actionId) + const requestedSessionId = actionData?.sessionIdOverride + const sessionId = + actionId === 'launch' && requestedSessionId === `benchmark:${installationId}` + ? requestedSessionId + : undefined + return dispatchSessionAction( + { event: _event, installationId, sessionId, inst, actionData }, + actionId + ) } ) } diff --git a/src/main/lib/ipc/sessionActions/launch.test.ts b/src/main/lib/ipc/sessionActions/launch.test.ts index 839e11d7e..65db7bd0b 100644 --- a/src/main/lib/ipc/sessionActions/launch.test.ts +++ b/src/main/lib/ipc/sessionActions/launch.test.ts @@ -36,7 +36,9 @@ import { handleLaunch, isCrashedExit, onProcessTerminated, - _cleanupFailedLaunchSetup + _cleanupFailedLaunchSetup, + _resolveLaunchMode, + _resolvePortConflictPolicy } from './launch' import type { ActionContext } from './types' import type * as ComfyDownloadManagerModule from '../../comfyDownloadManager' @@ -45,6 +47,7 @@ import { _markLaunching, _operationAborts, _pendingPorts, + _runningSessions, _reservePort } from '../shared' import type { ChildProcess, InstallationRecord } from '../shared' @@ -74,6 +77,58 @@ describe('desktopFeatureFlags', () => { }) }) +describe('_resolveLaunchMode', () => { + it('allows a launch-scoped console override without changing the installation', () => { + const installation = { ...installOf('standalone'), launchMode: 'window' } + + expect(_resolveLaunchMode(installation, { launchModeOverride: 'console' })).toBe('console') + expect(installation.launchMode).toBe('window') + }) + + it('uses the persisted mode for unsupported overrides', () => { + const installation = { ...installOf('standalone'), launchMode: 'window' } + + expect(_resolveLaunchMode(installation, { launchModeOverride: 'external' })).toBe('window') + }) +}) + +describe('_resolvePortConflictPolicy', () => { + it('allows a launch-scoped automatic port without changing the installation', () => { + const installation = { + ...installOf('standalone'), + launchArgs: '--enable-manager --port 8188', + portConflict: 'prompt' + } + + expect( + _resolvePortConflictPolicy( + installation, + { portConflict: 'prompt' }, + { + autoPortOnConflict: true + } + ) + ).toEqual({ mode: 'auto', portIsExplicit: false }) + expect(installation).toMatchObject({ + launchArgs: '--enable-manager --port 8188', + portConflict: 'prompt' + }) + }) + + it('preserves the configured policy and explicit port for normal launches', () => { + const installation = { + ...installOf('standalone'), + launchArgs: '--port=8188', + portConflict: 'prompt' + } + + expect(_resolvePortConflictPolicy(installation, { portConflict: 'auto' })).toEqual({ + mode: 'prompt', + portIsExplicit: true + }) + }) +}) + describe('isCrashedExit', () => { it('treats a clean exit (code 0, no signal) as not crashed', () => { expect(isCrashedExit(0, null)).toBe(false) @@ -212,6 +267,27 @@ describe('handleLaunch model-download startup await (#1322)', () => { modelStartup.impl = null }) + it('allows an isolated benchmark session while the installation is already running', async () => { + const installationId = 'running-install' + const sessionId = `benchmark:${installationId}` + _runningSessions.set(installationId, { + proc: null, + port: 8188, + mode: 'window', + installationName: 'Running Install', + startedAt: Date.now() + }) + + try { + const result = await handleLaunch({ ...ctxFor(installationId), sessionId }) + expect(result.message).toMatch(/unknownSource|unrecognized source/) + expect(result.message).not.toMatch(/alreadyRunning/i) + } finally { + _runningSessions.delete(installationId) + _operationAborts.delete(sessionId) + } + }) + it('never blocks the launch while incomplete files are visible under final model names', async () => { modelStartup.impl = async () => ({ safe: false, diff --git a/src/main/lib/ipc/sessionActions/launch.ts b/src/main/lib/ipc/sessionActions/launch.ts index adf2b7fb9..7af898660 100644 --- a/src/main/lib/ipc/sessionActions/launch.ts +++ b/src/main/lib/ipc/sessionActions/launch.ts @@ -307,6 +307,32 @@ function writeLog(stream: WriteStream, text: string): void { if (!stream.writableEnded) stream.write(stripAnsi(text)) } +export function _resolveLaunchMode( + inst: InstallationRecord, + actionData?: Record +): string { + if (actionData?.launchModeOverride === 'console') return 'console' + return (inst.launchMode as string | undefined) || 'window' +} + +export function _resolvePortConflictPolicy( + inst: InstallationRecord, + defaults: Record, + actionData?: Record +): { mode: string; portIsExplicit: boolean } { + const autoPortOnConflict = actionData?.autoPortOnConflict === true + return { + mode: autoPortOnConflict + ? 'auto' + : (inst.portConflict as string | undefined) || + (defaults.portConflict as string | undefined) || + 'auto', + portIsExplicit: + actionData?.portOverride != null || + (!autoPortOnConflict && /(?:^|\s)--port(?:\s|=|$)/.test(String(inst.launchArgs ?? ''))) + } +} + /** Failure cleanup for a throw after launch resources were acquired (launching * marker set, port possibly reserved) but before the normal failure handling * is reachable: close the log stream, release the port, free the operation @@ -328,7 +354,8 @@ export function _cleanupFailedLaunchSetup( export async function handleLaunch(ctx: ActionContext): Promise { const { installationId } = ctx - if (_runningSessions.has(installationId)) { + const sessionId = ctx.sessionId ?? installationId + if (_runningSessions.has(sessionId)) { return { ok: false, message: i18n.t('errors.alreadyRunning') } } // No `_hasActiveLaunch` here: this guard, `_beginLaunch`, and runLaunch's @@ -336,36 +363,36 @@ export async function handleLaunch(ctx: ActionContext): Promise { // launch can never slip between them. Checking it would instead reject a // legitimate restart relaunch during the post-registration window (session // up, handler still draining post-launch work like the template gate). - if (_operationAborts.has(installationId)) { + if (_operationAborts.has(sessionId)) { return { ok: false, message: 'Another operation is already running for this installation.' } } // Track the launch for its ENTIRE handler lifetime so `cancelLaunching` can // abort it at any point - including the pre-spawn prep that runs before the // launching marker exists. The finally is the single teardown-complete // signal: `cancelLaunching` awaits it before letting a restart relaunch. - const launch = _beginLaunch(installationId) + const launch = _beginLaunch(sessionId) try { return await runLaunch(ctx, launch.abort) } finally { - if (_operationAborts.get(installationId) === launch.abort) - _operationAborts.delete(installationId) - _endLaunch(installationId, launch) + if (_operationAborts.get(sessionId) === launch.abort) _operationAborts.delete(sessionId) + _endLaunch(sessionId, launch) } } async function runLaunch( - { event, installationId, inst: instArg, actionData }: ActionContext, + { event, installationId, sessionId: runtimeSessionId, inst: instArg, actionData }: ActionContext, abort: AbortController ): Promise { let inst = instArg + const sessionId = runtimeSessionId ?? installationId // Synthetic repair steps that ran during launch prep, prepended to the launch // progress in display order (e.g. a source rollback, then a PyTorch restore). const preLaunchPhases: PreLaunchPhase[] = [] // Claim the operation slot for the whole launch, prep included, so no other // operation can start against this install while the launch is preparing. - _operationAborts.set(installationId, abort) + _operationAborts.set(sessionId, abort) // Drop retained crash detail so the lifecycle view doesn't resurface it. - clearCrash(installationId) + clearCrash(sessionId) // The startup model-download pass (migrate legacy final-path partials, // hydrate staged `.part` downloads) runs to completion before ComfyUI can // scan the model dirs, so a truncated file cannot masquerade as a loadable @@ -418,7 +445,7 @@ async function runLaunch( } const sender = event.sender - const sendProgress = makeSendProgress(sender, installationId) + const sendProgress = makeSendProgress(sender, sessionId) // A build install whose background model staging never finished (crash, // abort, staging failure, or a record written before `modelsStaged` existed) @@ -471,7 +498,7 @@ async function runLaunch( // with `boot_failed`); a healthy boot discards them — `boot_started` // is already ~258k/14d and per-phase emits on every boot would be pure // volume. See `bootPhaseBuffer`. - onPhaseEnter: (phase) => recordBootPhase(installationId, phase) + onPhaseEnter: (phase) => recordBootPhase(sessionId, phase) }) launchTracker.start() return launchTracker @@ -487,7 +514,7 @@ async function runLaunch( try { return await step() } catch (err) { - _cleanupFailedLaunchSetup(installationId, abort, opts) + _cleanupFailedLaunchSetup(sessionId, abort, opts) throw err } } @@ -641,7 +668,7 @@ async function runLaunch( inst, { sendProgress, - sendOutput: makeSendOutput(event.sender, installationId), + sendOutput: makeSendOutput(event.sender, sessionId), update: updateFn, signal: abort.signal }, @@ -905,17 +932,19 @@ async function runLaunch( } }) } catch (_err) { - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) if (abort.signal.aborted) return { ok: false, cancelled: true } return { ok: false, message: i18n.t('errors.cannotConnect', { url: displayUrl }) } } - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) - const mode = (inst.launchMode as string | undefined) || 'window' + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) + const mode = _resolveLaunchMode(inst, actionData) _addSession( - installationId, + sessionId, { proc: null, port: launchCmd.port!, url: launchCmd.url, mode, installationName: inst.name }, - Date.now() - launchStartedAt + Date.now() - launchStartedAt, + undefined, + installationId ) if (_onLaunch) { _onLaunch({ @@ -931,20 +960,20 @@ async function runLaunch( // Local process launch if (!fs.existsSync(launchCmd.cmd!)) { - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) return { ok: false, message: i18n.t('errors.executableNotFound', { cmd: launchCmd.cmd ?? '' }) } } // Skip port logic entirely if (launchCmd.skipPortWait) { - const sendOutput = makeSendOutput(sender, installationId) + const sendOutput = makeSendOutput(sender, sessionId) const launchEnv = buildLaunchEnv(inst) // Marked inside the guard: even the marker's renderer broadcast can // throw, and every throw after the marker exists must clear it before // the handler settles. const { logStream, execTap, hwTap, tracker } = await guardLaunchSetup(() => { - _markLaunching(installationId, inst.name) + _markLaunching(sessionId, inst.name) return acquireLaunchResources() }) @@ -952,7 +981,7 @@ async function runLaunch( // during the awaits above must never spawn. if (abort.signal.aborted) { logStream.end() - _clearLaunchingFailed(installationId) + _clearLaunchingFailed(sessionId) return { ok: false, cancelled: true } } @@ -977,26 +1006,29 @@ async function runLaunch( { logStream } ) - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) - const mode = (inst.launchMode as string | undefined) || 'window' + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) + const mode = _resolveLaunchMode(inst, actionData) _addSession( - installationId, + sessionId, { proc, port: 0, mode, installationName: inst.name, + getAcceleratorInfo: () => hwTap.getAcceleratorInfo(), flushTelemetry: () => { execTap.flushSummary() hwTap.flushSummary() } }, - Date.now() - launchStartedAt + Date.now() - launchStartedAt, + undefined, + installationId ) onProcessTerminated(proc, async (code, signal) => { logStream.end() - const crashed = _runningSessions.has(installationId) && isCrashedExit(code, signal) + const crashed = _runningSessions.has(sessionId) && isCrashedExit(code, signal) // Raw stderr — this payload is shown to the user in the crashed-state // lifecycle UI. PII scrubbing happens on the telemetry path // (`scrubTelemetryContext` in renderer bootstrap), not here. @@ -1007,9 +1039,9 @@ async function runLaunch( // relaunch can't slip in and clearCrash() during the audit and have this // handler then resurrect the stale crash via recordCrash(). const crashDiagnosis = crashed ? await diagnoseCrash(code) : {} - _removeSession(installationId) + _removeSession(sessionId) const exitedPayload = { - installationId, + installationId: sessionId, crashed, exitCode: code ?? undefined, signal: signal ?? undefined, @@ -1034,7 +1066,7 @@ async function runLaunch( if (!sender.isDestroyed()) { sender.send('comfy-exited', exitedPayload) } - if (_onComfyExited) _onComfyExited({ installationId, crashed }) + if (_onComfyExited) _onComfyExited({ installationId: sessionId, crashed }) }) if (_onLaunch) { @@ -1048,13 +1080,11 @@ async function runLaunch( } const defaults = source.getDefaults ? source.getDefaults() : {} - const portConflictMode = - (inst.portConflict as string | undefined) || - (defaults.portConflict as string | undefined) || - 'auto' - const userArgs = ((inst.launchArgs as string | undefined) || '').trim() - const portIsExplicit = - actionData?.portOverride != null || /(?:^|\s)--port(?:\s|=|$)/.test(userArgs) + const { mode: portConflictMode, portIsExplicit } = _resolvePortConflictPolicy( + inst, + defaults, + actionData + ) // isPortListening (bind test) is the primary check; findPidsByPort's lsof // only sees same-user processes on Linux. @@ -1114,7 +1144,7 @@ async function runLaunch( }) } } - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) return { ok: false, message, @@ -1144,7 +1174,7 @@ async function runLaunch( }) setPortArg(launchCmd as LaunchCmd, nextPort) } else { - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) return { ok: false, message: i18n.t('errors.portConflictLauncher', { @@ -1164,7 +1194,7 @@ async function runLaunch( // throw here has nothing to clean up yet. const sessionPath = createSessionPath() const launchEnv = buildLaunchEnv(inst, sessionPath) - const sendOutput = makeSendOutput(sender, installationId) + const sendOutput = makeSendOutput(sender, sessionId) // Port reservation and launching marker sit INSIDE the guard: even the // marker's renderer broadcast can throw, and every throw after either @@ -1173,7 +1203,7 @@ async function runLaunch( const { logStream, execTap, hwTap, tracker } = await guardLaunchSetup( () => { _reservePort(launchCmd.port!, inst.name) - _markLaunching(installationId, inst.name) + _markLaunching(sessionId, inst.name) return acquireLaunchResources() }, { port: launchCmd.port! } @@ -1234,9 +1264,9 @@ async function runLaunch( .join(' ') sendProgress('launch', { percent: -1, status: i18n.t('launch.starting') }) if (!sender.isDestroyed()) { - sender.send('comfy-output', { installationId, text: `> ${cmdLine}\n\n` }) + sender.send('comfy-output', { installationId: sessionId, text: `> ${cmdLine}\n\n` }) } - appendLog(installationId, `> ${cmdLine}\n\n`) + appendLog(sessionId, `> ${cmdLine}\n\n`) // Explicit boot-attempt event. `installation_started` already fires // on successful boot with `boot_time_ms`, and `comfyui.exited` carries // `crashed=true` on failure — but boot success rate needed inferred @@ -1254,7 +1284,7 @@ async function runLaunch( // reboot retry this resets so the buffer reflects the attempt that // actually fails (or succeeds). The tracker's `onPhaseEnter` feeds it; // it is flushed only on the terminal failure path below. - startBootPhases(installationId, (inst.variant as string | undefined) ?? null) + startBootPhases(sessionId, (inst.variant as string | undefined) ?? null) // Re-arm per-attempt phase observation: the UI tracker's index is // monotonic across retries, so without this the respawned boot's re-hit // milestones would never reach the fresh buffer above. @@ -1391,9 +1421,9 @@ async function runLaunch( logStream.end() _releasePort(launchCmd.port!) // Ownership-guarded: never evict a slot a newer operation already claimed. - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) abort.abort() // stop the template-models reader timer on launch failure - _clearLaunchingFailed(installationId) + _clearLaunchingFailed(sessionId) // Flush the hardware tap on terminal failure/cancel too: the exit handler // covers a process that exits, but a waitForPort timeout can return here // with the proc still alive, leaving a pending accelerator event unemitted. @@ -1402,7 +1432,7 @@ async function runLaunch( if (launchResult.cancelled) { // User-initiated cancel is not a boot failure — discard the buffer so a // later relaunch starts clean and we don't emit phantom boot_phase rows. - clearBootPhases(installationId) + clearBootPhases(sessionId) return { ok: false, cancelled: true } } execTap.flushSummary() @@ -1412,7 +1442,7 @@ async function runLaunch( // emit the paired boot_failed. `failed_phase` is the last phase the boot // reached (null if it never entered one). The error is bucketed; the // retry counters surface how many times we re-spawned before giving up. - const failedPhase = flushBootPhasesOnFailure(installationId) + const failedPhase = flushBootPhasesOnFailure(sessionId) // Standard error schema derived from the failure message + the stderr // tail (a Python traceback in the tail yields a real `error_class` / // `error_message`; otherwise the launch message drives it). `error_tail` @@ -1440,27 +1470,29 @@ async function runLaunch( } // Healthy boot — discard buffered phase timings (no boot_phase on success; // healthy timing is covered by instance_started.boot_time_ms). - clearBootPhases(installationId) + clearBootPhases(sessionId) let { proc } = launchResult _pendingPorts.delete(launchCmd.port!) - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) - const mode = (inst.launchMode as string | undefined) || 'window' + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) + const mode = _resolveLaunchMode(inst, actionData) const bootTimeMs = Date.now() - launchStartedAt _addSession( - installationId, + sessionId, { proc, port: launchCmd.port!, mode, installationName: inst.name, + getAcceleratorInfo: () => hwTap.getAcceleratorInfo(), flushTelemetry: () => { execTap.flushSummary() hwTap.flushSummary() } }, bootTimeMs, - { portRetries, rebootRetries } + { portRetries, rebootRetries }, + installationId ) // Paired success terminal for boot_started: server up + session registered. // Same boot_id as this launch's boot_started(s), so the boot-success rate is @@ -1478,7 +1510,7 @@ async function runLaunch( if (!sender.isDestroyed()) { // Raw bootStderr — telemetry forwarders scrub it before it leaves the box. const bootStderr = lastNLines(launchResult.getStderr(), 50) - sender.send('comfy-boot-log', { installationId, bootStderr }) + sender.send('comfy-boot-log', { installationId: sessionId, bootStderr }) } // Capture snapshot in background after successful launch @@ -1505,12 +1537,12 @@ async function runLaunch( if (newFolders.length > 0) { sendOutput(`\n--- Restarting: new model folders detected (${newFolders.join(', ')}) ---\n\n`) if (_onModelFolderRelaunch) { - await Promise.resolve(_onModelFolderRelaunch({ installationId })).catch(() => {}) + await Promise.resolve(_onModelFolderRelaunch({ installationId: sessionId })).catch(() => {}) } await killProcessTree(proc) const respawned = await spawnComfy() proc = respawned.proc - const session = _runningSessions.get(installationId) + const session = _runningSessions.get(sessionId) if (session) session.proc = proc writePortLock(launchCmd.port!, { pid: proc.pid!, installationName: inst.name }) const relaunchEarlyExit = new Promise((_resolve, reject) => { @@ -1538,8 +1570,8 @@ async function runLaunch( // flushSummary is idempotent. execTap.flushSummary() hwTap.flushSummary() - _removeSession(installationId) - _clearLaunchingFailed(installationId) + _removeSession(sessionId) + _clearLaunchingFailed(sessionId) if (abort.signal.aborted) return { ok: false, cancelled: true } return { ok: false, message: (err as Error).message } } @@ -1589,11 +1621,11 @@ async function runLaunch( const spawned = await spawnComfy() proc = spawned.proc currentGetStderr = spawned.getStderr - const session = _runningSessions.get(installationId) + const session = _runningSessions.get(sessionId) if (session) session.proc = proc writePortLock(launchCmd.port!, { pid: proc.pid!, installationName: inst.name }) attachExitHandler(proc) - if (_onComfyRestarted) _onComfyRestarted({ installationId, process: proc }) + if (_onComfyRestarted) _onComfyRestarted({ installationId: sessionId, process: proc }) if (manageModelFolders) { rebootModelCheckAbort = new AbortController() const checkSignal = rebootModelCheckAbort.signal @@ -1603,7 +1635,7 @@ async function runLaunch( }) .then(async () => { if (checkSignal.aborted) return - const currentSession = _runningSessions.get(installationId) + const currentSession = _runningSessions.get(sessionId) if (!currentSession || currentSession.proc !== proc) return const currentExtras = discoverExtraModelFolders(inst.installPath) const newFolders = currentExtras.filter((f) => !knownExtras.has(f)) @@ -1623,7 +1655,9 @@ async function runLaunch( ) pendingModelFolderRelaunch = true if (_onModelFolderRelaunch) { - await Promise.resolve(_onModelFolderRelaunch({ installationId })).catch(() => {}) + await Promise.resolve( + _onModelFolderRelaunch({ installationId: sessionId }) + ).catch(() => {}) } killProcessTree(proc) } @@ -1647,7 +1681,7 @@ async function runLaunch( return } logStream.end() - const crashed = _runningSessions.has(installationId) && isCrashedExit(code, signal) + const crashed = _runningSessions.has(sessionId) && isCrashedExit(code, signal) // Raw stderr — see note in the early-fail exit handler above. const lastStderr = lastNLines(currentGetStderr(), 100) execTap.flushSummary() @@ -1656,9 +1690,9 @@ async function runLaunch( // relaunch can't slip in and clearCrash() during the audit and have this // handler then resurrect the stale crash via recordCrash(). const crashDiagnosis = crashed ? await diagnoseCrash(code) : {} - _removeSession(installationId) + _removeSession(sessionId) const exitedPayload = { - installationId, + installationId: sessionId, crashed, exitCode: code ?? undefined, signal: signal ?? undefined, @@ -1683,7 +1717,7 @@ async function runLaunch( if (!sender.isDestroyed()) { sender.send('comfy-exited', exitedPayload) } - if (_onComfyExited) _onComfyExited({ installationId, crashed }) + if (_onComfyExited) _onComfyExited({ installationId: sessionId, crashed }) }) } attachExitHandler(proc) diff --git a/src/main/lib/ipc/sessionActions/types.ts b/src/main/lib/ipc/sessionActions/types.ts index 283195c1c..47d1ca754 100644 --- a/src/main/lib/ipc/sessionActions/types.ts +++ b/src/main/lib/ipc/sessionActions/types.ts @@ -3,6 +3,8 @@ import type { InstallationRecord } from '../shared' export interface ActionContext { event: Electron.IpcMainInvokeEvent installationId: string + /** Runtime identity when one installation owns an isolated secondary session. */ + sessionId?: string inst: InstallationRecord actionData?: Record } diff --git a/src/main/lib/ipc/shared.ts b/src/main/lib/ipc/shared.ts index 3d1e27c94..b7a7f81be 100644 --- a/src/main/lib/ipc/shared.ts +++ b/src/main/lib/ipc/shared.ts @@ -36,6 +36,7 @@ import { _broadcastToRenderer } from './broadcast' import { appendLog } from '../logsBroadcast' import { flushOperationOutput } from '../appLog' import { stripAnsi } from '../stderrTail' +import type { AcceleratorSnapshot } from '../hardwareTap' import { spawnProcess, waitForPort, @@ -277,9 +278,12 @@ export interface SessionInfo { url?: string mode: string installationName: string + sourceInstallationId?: string startedAt: number /** Synchronously queue final telemetry before app-level shutdown drains the SDK. */ flushTelemetry?: () => void + /** Latest accelerator details parsed from this session's ComfyUI startup logs. */ + getAcceleratorInfo?: () => AcceleratorSnapshot | null } export interface LaunchCallbackInfo { @@ -1057,7 +1061,9 @@ export function _addSession( * renderer's `instance_started` telemetry can carry them without a * separate `server_ready` event. Omitted for the remote / skip-port paths * (no spawn retry there). */ - retries?: { portRetries: number; rebootRetries: number } + retries?: { portRetries: number; rebootRetries: number }, + /** Durable installation identity when the runtime session uses an isolated key. */ + sourceInstallationId: string = installationId ): void { _runningSessions.set(installationId, { proc, @@ -1065,6 +1071,7 @@ export function _addSession( url, mode, installationName, + sourceInstallationId, flushTelemetry, startedAt: Date.now() }) @@ -1087,7 +1094,7 @@ export function _addSession( // callback could fire. Fire-and-forget; never blocks the launch. if (_onInstanceStarted) { _onInstanceStarted({ - installationId, + installationId: sourceInstallationId, bootTimeMs, portRetries: retries?.portRetries ?? 0, rebootRetries: retries?.rebootRetries ?? 0 @@ -1095,7 +1102,7 @@ export function _addSession( } // Stamps lastLaunchedAt + per-category recency so those surfaces needn't scan every record. installations - .markLaunched(installationId, (inst) => sourceMap[inst.sourceId]?.category) + .markLaunched(sourceInstallationId, (inst) => sourceMap[inst.sourceId]?.category) .then(() => _broadcastToRenderer('installations-changed', {})) .catch((err) => { console.error('Failed to mark installation launched:', err) @@ -1126,6 +1133,14 @@ export function _getPublicSessions(): Record[] { })) } +export function hasRunningSessionForInstallation(installationId: string): boolean { + return Array.from( + _runningSessions, + ([sessionId, session]) => + sessionId === installationId || session.sourceInstallationId === installationId + ).some(Boolean) +} + /** * Build the installation snapshot/disk context for the * `get-installation-dd-context` IPC handler and the main-process diff --git a/src/main/popups/titlePopup.test.ts b/src/main/popups/titlePopup.test.ts index 3826c7951..c451a145f 100644 --- a/src/main/popups/titlePopup.test.ts +++ b/src/main/popups/titlePopup.test.ts @@ -165,10 +165,14 @@ describe('buildTitlePopupMenuItems', () => { expect(ids).toContain('load-snapshot') }) - it('chooser host includes New Window, Settings, Send Feedback, Close Window, and Quit Desktop', () => { + it('chooser host includes New Window, Performance Test, Settings, Send Feedback, Close Window, and Quit Desktop', () => { const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) const ids = items.map((i) => i.id ?? null) expect(ids).toContain('new-window') + expect(ids).toContain('performance-benchmarks') + expect(items.find((item) => item.id === 'performance-benchmarks')?.label).toBe( + 'Performance Test' + ) expect(ids).toContain('settings') expect(ids).toContain('feedback') expect(ids).toContain('exit-window') @@ -186,6 +190,7 @@ describe('buildTitlePopupMenuItems', () => { 'track', 'load-snapshot', 'sign-in', + 'performance-benchmarks', 'settings', 'feedback', 'exit-window', @@ -205,6 +210,7 @@ describe('buildTitlePopupMenuItems', () => { 'track', 'load-snapshot', 'sign-in', + 'performance-benchmarks', 'settings', 'feedback', 'exit-window', @@ -225,6 +231,7 @@ describe('buildTitlePopupMenuItems', () => { 'new-install', 'track', 'load-snapshot', + 'performance-benchmarks', 'settings', 'feedback', 'exit-window', @@ -241,6 +248,7 @@ describe('buildTitlePopupMenuItems', () => { 'track', 'load-snapshot', 'sign-in', + 'performance-benchmarks', 'settings', 'feedback', 'reset-zoom', @@ -294,19 +302,26 @@ describe('buildTitlePopupMenuItems', () => { expect(ids[ids.length - 1]).toBe('close-all-windows') }) - it('separates Log in from Desktop Settings while signed out', () => { + it('separates Log in from the Performance Test and Desktop Settings group', () => { const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) const signInIdx = items.findIndex((i) => i.id === 'sign-in') expect(items[signInIdx + 1]?.kind).toBe('separator') - expect(items[signInIdx + 2]?.id).toBe('settings') + expect(items[signInIdx + 2]?.id).toBe('performance-benchmarks') + expect(items[signInIdx + 3]?.id).toBe('settings') }) - it('does not leave a doubled separator above Desktop Settings once signed in', () => { + it('does not leave a doubled separator above Performance Test once signed in', () => { devPlatformMocks.isSignedInToCloud.mockReturnValue(true) + const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) + const benchmarksIdx = items.findIndex((i) => i.id === 'performance-benchmarks') + expect(items[benchmarksIdx - 1]?.kind).toBe('separator') + expect(items[benchmarksIdx - 2]?.kind).not.toBe('separator') + }) + + it('places Performance Test immediately above Desktop Settings', () => { const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) const settingsIdx = items.findIndex((i) => i.id === 'settings') - expect(items[settingsIdx - 1]?.kind).toBe('separator') - expect(items[settingsIdx - 2]?.kind).not.toBe('separator') + expect(items[settingsIdx - 1]?.id).toBe('performance-benchmarks') }) it('separators bracket the install-creation block on both hosts', () => { @@ -344,6 +359,18 @@ describe('activateTitlePopupMenuItem', () => { } as unknown as Parameters[0] } + it('opens Performance Test in a fresh chooser-shaped host', () => { + const host = makeEntry({ installationId: null }) + comfyWindows.set(host.windowKey, host) + const bindings = { + openChooserHostWindow: vi.fn() + } as unknown as TitlePopupHostBindings + + activateTitlePopupMenuItem(makePopupEntry(host.windowKey), 'performance-benchmarks', bindings) + + expect(bindings.openChooserHostWindow).toHaveBeenCalledExactlyOnceWith('performance-benchmarks') + }) + it('routes Reset Zoom through resetComfyZoom with the host installation id', () => { const host = makeEntry({ installationId: 'inst-1', zoomLevel: 3 }) comfyWindows.set(host.windowKey, host) @@ -651,6 +678,50 @@ describe('buildInstancePickerSnapshot', () => { }) }) +describe('title popup renderer readiness', () => { + type IpcListener = (event: Electron.IpcMainEvent) => void + let ready: IpcListener + + beforeAll(async () => { + const { ipcMain } = await import('electron') + registerTitlePopupIpc({} as TitlePopupHostBindings) + const call = vi + .mocked(ipcMain.on) + .mock.calls.find(([channel]) => channel === 'comfy-titlepopup:ready') + if (!call) throw new Error('IPC listener not registered: comfy-titlepopup:ready') + ready = call[1] as IpcListener + }) + + afterAll(() => { + _test_deleteTitlePopupEntry(404) + }) + + it('replays the last config when the cached popup renderer reloads', () => { + const config = { + kind: 'menu' as const, + items: [{ id: 'settings', label: 'Desktop Settings' }], + theme: { bg: '#111111', text: '#eeeeee' } + } + const send = vi.fn() + const entry = { + view: { + rendererReady: false, + popup: { webContents: { isDestroyed: () => false, send } } + }, + pendingConfig: null, + lastConfigJson: JSON.stringify(config), + lastSyncedConfigJson: JSON.stringify(config) + } as unknown as TitlePopupEntry + _test_setTitlePopupEntry(404, entry) + + ready({ sender: { id: 404 } } as Electron.IpcMainEvent) + + expect(entry.view.rendererReady).toBe(true) + expect(entry.lastSyncedConfigJson).toBeNull() + expect(send).toHaveBeenCalledExactlyOnceWith('comfy-titlepopup:set-config', config) + }) +}) + describe('global settings IPC handlers', () => { type IpcHandler = ( event: Electron.IpcMainInvokeEvent, diff --git a/src/main/popups/titlePopup.ts b/src/main/popups/titlePopup.ts index b7a73c7ba..ce6308913 100644 --- a/src/main/popups/titlePopup.ts +++ b/src/main/popups/titlePopup.ts @@ -749,6 +749,7 @@ export function buildTitlePopupMenuItems(entry: ComfyWindowEntry): TitlePopupMen // Load Snapshot // ── separator ── // (Log in — while signed out, followed by its own separator) + // Performance Test // Desktop Settings // Send Beta Feedback // (Reset Zoom — on install-backed hosts when zoom level != 0) @@ -797,6 +798,11 @@ export function buildTitlePopupMenuItems(entry: ComfyWindowEntry): TitlePopupMen ) } items.push( + { + id: 'performance-benchmarks', + label: 'Performance Test', + labelKey: 'fileMenu.performanceBenchmarks' + }, { id: 'settings', label: 'Desktop Settings', @@ -1957,6 +1963,9 @@ export function activateTitlePopupMenuItem( if (id === 'new-window') { bindings.openChooserHostWindow() releaseFocusToParent = false + } else if (id === 'performance-benchmarks') { + bindings.openChooserHostWindow('performance-benchmarks') + releaseFocusToParent = false } else if (id === 'return-to-dashboard') { // Flip the install-backed host in place to chooser-host mode. // The same BrowserWindow stays alive; the file-menu popup is @@ -2263,8 +2272,16 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { const entry = titlePopupsByWebContents.get(event.sender.id) if (!entry) return entry.view.rendererReady = true - if (entry.pendingConfig && !entry.view.popup.webContents.isDestroyed()) { - const flushed = entry.pendingConfig + // A ready signal means this is a freshly-mounted renderer. In development, + // Vite can reload the cached popup WebContentsView while main still holds + // the prior sync marker; replay that config so the default empty menu state + // cannot be mistaken for an already-synchronised renderer on the next open. + entry.lastSyncedConfigJson = null + const queuedConfig = + entry.pendingConfig ?? + (entry.lastConfigJson ? (JSON.parse(entry.lastConfigJson) as TitlePopupConfig) : null) + if (queuedConfig && !entry.view.popup.webContents.isDestroyed()) { + const flushed = queuedConfig entry.lastConfigJson = JSON.stringify(flushed) entry.view.popup.webContents.send('comfy-titlepopup:set-config', flushed) entry.pendingConfig = null diff --git a/src/preload/api.ts b/src/preload/api.ts index e68304902..1328fad92 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -28,6 +28,12 @@ export function buildElectronApi(): ElectronApi { // File/URL browseFolder: (defaultPath?) => ipcRenderer.invoke('browse-folder', defaultPath), + importBenchmarkWorkflow: (filePath?) => + ipcRenderer.invoke('import-benchmark-workflow', filePath), + deleteBenchmarkWorkflow: (filePath) => + ipcRenderer.invoke('delete-benchmark-workflow', filePath), + runBenchmarkWorkflow: (sessionId, filePath, measuredRuns) => + ipcRenderer.invoke('run-benchmark-workflow', sessionId, filePath, measuredRuns), openPath: (targetPath) => ipcRenderer.invoke('open-path', targetPath), openExternal: (url) => ipcRenderer.invoke('open-external', url), getDiskSpace: (targetPath) => ipcRenderer.invoke('get-disk-space', targetPath), diff --git a/src/preload/comfyTitleBarPreload.ts b/src/preload/comfyTitleBarPreload.ts index fe387790b..6f59f1831 100644 --- a/src/preload/comfyTitleBarPreload.ts +++ b/src/preload/comfyTitleBarPreload.ts @@ -3,7 +3,13 @@ import type { IpcRendererEvent } from 'electron' import { buildElectronApi } from './api' import { normaliseFirstUseMode, type FirstUseMode } from '../shared/firstUseMode' -export type ComfyPanelKey = 'comfy' | 'new-install' | 'track' | 'load-snapshot' | 'quick-install' +export type ComfyPanelKey = + | 'comfy' + | 'performance-benchmarks' + | 'new-install' + | 'track' + | 'load-snapshot' + | 'quick-install' /** Anchor coordinates for a native title-bar menu — title-bar-local * pixels (x = button left, y = button bottom). The titleBarView sits diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 778c7a0f6..9b01deeae 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -29,7 +29,13 @@ const { syncLocale } = useAppLocale(windowApiLocaleSource()) // file isn't visible to tsconfig.web (only its .d.ts would be). Kept in // sync with the literal union in src/preload/comfyTitleBarPreload.ts and // the ComfyPanelKey export in src/main/index.ts. -type ComfyPanelKey = 'comfy' | 'new-install' | 'track' | 'load-snapshot' | 'quick-install' +type ComfyPanelKey = + | 'comfy' + | 'performance-benchmarks' + | 'new-install' + | 'track' + | 'load-snapshot' + | 'quick-install' /** Position passed to main so the native menu pops below the anchor button. * Coordinates are in title-bar-local pixels — main translates to window diff --git a/src/renderer/src/composables/useInstallContextMenu.ts b/src/renderer/src/composables/useInstallContextMenu.ts index 01ee11f7c..334c9631e 100644 --- a/src/renderer/src/composables/useInstallContextMenu.ts +++ b/src/renderer/src/composables/useInstallContextMenu.ts @@ -383,6 +383,36 @@ export function useInstallContextMenu( ctxMenu.value.open = false } + /** Surface either an operation failure or a crashed instance's details. */ + function viewError(inst: Installation): void { + const err = sessionStore.errorInstances.get(inst.id) + if (!err) return + let message = err.message + if (!message) { + if (err.signal && err.exitCode != null) { + message = t('comfyLifecycle.crashedDescWithCodeAndSignal', { + code: err.exitCode, + signal: err.signal + }) + } else if (err.signal) { + message = t('comfyLifecycle.crashedDescWithSignal', { signal: err.signal }) + } else if (err.exitCode != null) { + message = t('comfyLifecycle.crashedDescWithCode', { code: err.exitCode }) + } else { + message = t('comfyLifecycle.crashedDesc') + } + } + if (err.lastStderr) message = `${message}\n\n${err.lastStderr}` + void modal.alert({ title: t('chooser.errorTitle'), message }) + } + + /** Surface the full explanation for a backend-provided danger status. */ + function viewDanger(inst: Installation): void { + const tag = inst.statusTag + if (!tag || tag.style !== 'danger') return + void modal.alert({ title: tag.label, message: tag.detail || tag.label }) + } + return { ctxMenu, ctxMenuItems, @@ -391,6 +421,8 @@ export function useInstallContextMenu( handleCtxMenuSelect, closeMenu, triggerAction, + viewError, + viewDanger, isStoppedActionGated, isPromotingToWorkspace } diff --git a/src/renderer/src/composables/useWorkspaceInstallScope.ts b/src/renderer/src/composables/useWorkspaceInstallScope.ts new file mode 100644 index 000000000..525b6330f --- /dev/null +++ b/src/renderer/src/composables/useWorkspaceInstallScope.ts @@ -0,0 +1,56 @@ +import { computed, ref, watch, type Ref } from 'vue' +import { useAuthStore } from '../stores/authStore' +import type { Installation } from '../types/ipc' + +/** Keeps install lists scoped to the workspace selected in the shared dashboard control. */ +export function useWorkspaceInstallScope(installations: Ref) { + const authStore = useAuthStore() + const selectedWorkspaceId = ref(null) + let initialized = false + + watch( + () => ({ signedIn: authStore.isSignedIn, workspaceId: authStore.status.workspaceId }), + (next, previous) => { + if (!next.signedIn) { + selectedWorkspaceId.value = null + initialized = false + return + } + + if (next.workspaceId && next.workspaceId !== previous?.workspaceId) { + void authStore.fetchBuilds() + } + + if (!initialized) { + selectedWorkspaceId.value = next.workspaceId ?? null + initialized = true + return + } + + if ( + selectedWorkspaceId.value !== null && + selectedWorkspaceId.value === previous?.workspaceId + ) { + selectedWorkspaceId.value = next.workspaceId ?? null + } + }, + { immediate: true } + ) + + function installationIsInSelectedScope(installation: Installation): boolean { + if (!authStore.isSignedIn) return installation.workspaceId === undefined + return selectedWorkspaceId.value === null + ? installation.workspaceId === undefined + : installation.workspaceId === selectedWorkspaceId.value + } + + const scopedInstallations = computed(() => + installations.value.filter(installationIsInSelectedScope) + ) + + return { + selectedWorkspaceId, + installationIsInSelectedScope, + scopedInstallations + } +} diff --git a/src/renderer/src/lib/openInstallManager.ts b/src/renderer/src/lib/openInstallManager.ts new file mode 100644 index 000000000..d21d5d437 --- /dev/null +++ b/src/renderer/src/lib/openInstallManager.ts @@ -0,0 +1,25 @@ +import { resolvePickerTab } from './pickerTabs' +import type { Installation } from '../types/ipc' + +export interface OpenInstallManagerOptions { + initialTab?: string + autoAction?: string | null +} + +/** Open the shared instance manager, optionally targeting and firing an action. */ +export function openInstallManager( + installation: Installation, + opts: OpenInstallManagerOptions = {} +): void { + const hasSpecialisedOpts = + opts.initialTab !== undefined || (opts.autoAction !== undefined && opts.autoAction !== null) + if (!hasSpecialisedOpts) { + window.api.openInstancePicker({ installationId: installation.id }) + return + } + window.api.openInstancePicker({ + installationId: installation.id, + initialTab: resolvePickerTab(opts.initialTab, 'status'), + autoAction: opts.autoAction ?? null + }) +} diff --git a/src/renderer/src/panel/PanelApp.test.ts b/src/renderer/src/panel/PanelApp.test.ts index e48e2b45a..9fcca9125 100644 --- a/src/renderer/src/panel/PanelApp.test.ts +++ b/src/renderer/src/panel/PanelApp.test.ts @@ -142,6 +142,54 @@ const messages = { }, common: { loading: 'Loading…' + }, + settings: { + logs: 'Logs' + }, + performanceBenchmarks: { + description: 'Run performance measurements against your ComfyUI instances.', + selectInstance: '1. Select an instance', + selectInstancePlaceholder: 'Select an instance', + dropWorkflow: '2. Drop a workflow in API format', + dropWorkflowHint: 'Drop a workflow .json file here, or click to browse', + importingWorkflow: 'Importing workflow...', + importFailed: 'Could not import the workflow.', + deleteWorkflow: 'Delete workflow', + deleteFailed: 'Could not delete the workflow.', + run: 'Run', + stop: 'Stop', + stopping: 'Stopping...', + stopFailed: 'Could not stop the instance.', + measurementSettings: '3. Set measurements settings', + measuredRuns: 'Measured runs', + logsPlaceholder: 'Instance logs will appear here.', + results: 'Results', + resultsPlaceholder: 'Benchmark results will appear here.', + fastestJob: 'Fastest job', + slowestJob: 'Slowest job', + averageDuration: 'Average job duration', + medianDuration: 'Median job duration', + hardware: 'Hardware', + device: 'Compute device', + vram: 'VRAM', + ram: 'RAM', + pytorch: 'PyTorch', + xformers: 'xFormers', + systemInformation: 'System information', + operatingSystem: 'Operating system', + architecture: 'Architecture', + cpu: 'CPU', + logicalCpuCores: 'Logical CPU cores', + physicalCpuCores: 'Physical CPU cores', + systemMemory: 'System memory', + systemGpu: 'System GPU', + gpuDriver: 'GPU driver', + starting: 'Starting...', + launchFailed: 'Failed to start the instance.', + submittingRuns: 'Submitting {preparationCount} preparation runs and {count} measured runs...', + completedRuns: + 'Finished {count} measured runs ({unsuccessful} unsuccessful). Final response saved to {path}', + submitFailed: 'Failed to submit the benchmark workflow.' } } } @@ -155,6 +203,11 @@ interface InstallationLike { name: string sourceLabel: string sourceCategory: string + sourceId?: string + workspaceId?: string + status?: string + version?: string + statusTag?: { style: string; label: string; detail?: string } } type PanelTriggerPayload = { @@ -224,6 +277,20 @@ function installMockApi(initial?: { getLocaleMessages: vi.fn().mockResolvedValue(messages.en), getLocale: vi.fn().mockResolvedValue('en'), onLocaleChanged: vi.fn(() => () => {}), + comfybuilder: { + getAuthStatus: vi.fn(async () => ({ + signedIn: true, + workspaceId: 'workspace-1', + workspaceName: 'Workspace One', + workspaceType: 'team' + })), + signIn: vi.fn(async () => ({ signedIn: true })), + signOut: vi.fn(async () => ({ signedIn: false })), + listWorkspaces: vi.fn(async () => []), + listBuilds: vi.fn(async () => []), + switchWorkspace: vi.fn(async () => ({ signedIn: true })), + onAuthChanged: vi.fn(() => () => {}) + }, onPanelSwitch: vi.fn((cb: (d: { panel: string; installationId?: string }) => void) => { state.panelSwitchCallbacks.push(cb) return () => {} @@ -286,6 +353,8 @@ function installMockApi(initial?: { onInstanceStarted: vi.fn(() => () => {}), onInstanceStopped: vi.fn(() => () => {}), onInstanceStopping: vi.fn(() => () => {}), + stopComfyUI: vi.fn(async () => {}), + cancelOperation: vi.fn(async () => {}), onComfyOutput: vi.fn(() => () => {}), onComfyExited: vi.fn(() => () => {}), onInstanceCrashed: vi.fn(() => () => {}), @@ -320,6 +389,74 @@ function installMockApi(initial?: { // Picker thumbnail warm-up fetches the bundled-template options on the // first-use cold-start path; returning users must never trigger it. getFieldOptions: vi.fn(async () => []), + getPathForFile: vi.fn((file: File) => `C:\\incoming\\${file.name}`), + importBenchmarkWorkflow: vi.fn(async () => ({ + ok: true, + filePath: 'C:\\ComfyUI\\benchmarks\\20260907225500\\cat-workflow.json' + })), + deleteBenchmarkWorkflow: vi.fn(async () => ({ ok: true })), + runBenchmarkWorkflow: vi.fn( + async (_sessionId: string, _filePath: string, measuredRuns: number) => ({ + ok: true, + submitted: measuredRuns, + preparationRuns: 2, + totalSubmitted: measuredRuns + 2, + promptIds: Array.from({ length: measuredRuns + 2 }, (_, index) => `prompt-${index + 1}`), + resultPath: 'C:\\ComfyUI\\benchmarks\\20260907225600\\results.json', + unsuccessfulJobs: 0, + statistics: { + fastest: { jobId: 'prompt-3', durationSeconds: 1.25 }, + slowest: { jobId: 'prompt-7', durationSeconds: 2.75 }, + averageDurationSeconds: 2, + medianDurationSeconds: 1.875, + measuredJobCount: measuredRuns + }, + hardware: { + deviceType: 'cuda', + deviceIndex: 0, + deviceName: 'NVIDIA GeForce RTX 4090', + backend: 'native', + devices: [], + vramMb: 24576, + ramMb: 65461, + pytorchVersion: '2.10.0+cu130', + xformersVersion: '0.0.31', + cudaDeviceSet: 0 + }, + systemInfo: { + gpu_vendor: 'nvidia', + gpu_label: 'NVIDIA', + gpu_model: 'NVIDIA GeForce RTX 4090', + gpu_vram_mb: 24576, + gpu_vram_gb: 24, + gpu_tier: 'high', + gpus: [], + nvidia_driver_version: '580.88', + nvidia_driver_supported: true, + amd_driver_version: null, + intel_driver_version: null, + platform: 'win32', + arch: 'x64', + os_version: '10.0.26200', + os_distro: 'Microsoft Windows 11 Pro', + os_release: '10.0.26200', + os_arch: '64-bit', + electron_version: '37.2.3', + chrome_version: '138.0.7204.100', + total_memory_gb: 64, + cpu_model: 'AMD Ryzen 9 7950X', + cpu_cores: 32, + cpu_physical_cores: 16, + cpu_speed_ghz: 4.5, + cpu_manufacturer: 'AMD', + app_version: '1.0.47', + auto_update: true, + locale: 'en', + installation_count: 1, + installations: [] + } + }) + ), openGlobalSettings: vi.fn(), openInstancePicker: vi.fn() } @@ -410,6 +547,288 @@ describe('PanelApp', () => { expect(lifecycle.attributes('data-installation-id')).toBe('test-id') }) + it('renders the performance benchmarks body with scoped instance rows', async () => { + mockState.installations = [ + { + ...SAMPLE_INSTALL, + id: 'workspace-install', + name: 'Workspace Install', + sourceId: 'standalone', + status: 'installed', + version: '0.3.50', + statusTag: { style: 'update', label: 'Update to 0.3.51' }, + workspaceId: 'workspace-1' + }, + { + ...SAMPLE_INSTALL, + id: 'migrate-install', + name: 'Legacy Install', + sourceId: 'legacy-desktop', + sourceCategory: 'local', + status: 'installed', + statusTag: { style: 'migrate', label: 'Migrate' }, + workspaceId: 'workspace-1' + }, + { + ...SAMPLE_INSTALL, + id: 'danger-install', + name: 'Missing Install', + sourceId: 'standalone', + sourceCategory: 'local', + status: 'installed', + statusTag: { + style: 'danger', + label: 'Folder Not Found', + detail: 'The instance folder could not be found.' + }, + workspaceId: 'workspace-1' + }, + { + ...SAMPLE_INSTALL, + id: 'other-workspace-install', + name: 'Other Workspace Install', + sourceId: 'standalone', + status: 'installed', + workspaceId: 'workspace-2' + }, + { + ...SAMPLE_INSTALL, + id: 'unmanaged-install', + name: 'Unmanaged Install', + sourceId: 'standalone', + status: 'installed' + }, + { + ...SAMPLE_INSTALL, + id: 'cloud-install', + name: 'Comfy Cloud Instance', + sourceId: 'cloud', + sourceLabel: 'Comfy Cloud', + sourceCategory: 'cloud', + status: 'installed' + } + ] + window.history.replaceState({}, '', '/?panel=performance-benchmarks&firstUseCompleted=true') + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.find('[data-testid="performance-benchmarks"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="performance-benchmarks-logo"]').exists()).toBe(true) + expect(wrapper.find('.performance-benchmarks__account').exists()).toBe(true) + expect(wrapper.find('[data-testid="devplatform-workspace-selector"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="workspace-selector-refresh"]').exists()).toBe(true) + expect(wrapper.find('.workspace-selector-bar__divider').exists()).toBe(true) + expect(wrapper.find('.performance-benchmarks__columns').exists()).toBe(true) + expect(wrapper.findAll('.performance-benchmarks__column')).toHaveLength(3) + expect( + wrapper.findAll('.performance-benchmarks__column h2').map((heading) => heading.text()) + ).toEqual([ + '1. Select an instance', + '2. Drop a workflow in API format', + '3. Set measurements settings' + ]) + expect(wrapper.get('.performance-benchmarks__setting').text()).toContain('Measured runs') + const measuredRunsInput = wrapper.get('.performance-benchmarks__setting input') + expect(measuredRunsInput.element).toHaveProperty('value', '5') + expect(measuredRunsInput.attributes()).toMatchObject({ + min: '1', + max: '100', + step: '1' + }) + await measuredRunsInput.setValue('101') + expect(measuredRunsInput.element).toHaveProperty('value', '100') + await measuredRunsInput.setValue('0') + expect(measuredRunsInput.element).toHaveProperty('value', '1') + await measuredRunsInput.setValue('4.6') + expect(measuredRunsInput.element).toHaveProperty('value', '5') + await measuredRunsInput.setValue('') + expect(measuredRunsInput.element).toHaveProperty('value', '5') + const logsToggle = wrapper.get('.performance-benchmarks__logs-toggle') + expect(logsToggle.text()).toBe('Logs') + expect(logsToggle.attributes('aria-expanded')).toBe('true') + const resultsToggle = wrapper.get('.performance-benchmarks__results-section button') + expect(resultsToggle.text()).toBe('Results') + expect(resultsToggle.attributes('aria-expanded')).toBe('true') + expect(wrapper.get('.performance-benchmarks__results').text()).toContain( + 'Benchmark results will appear here.' + ) + await resultsToggle.trigger('click') + expect(resultsToggle.attributes('aria-expanded')).toBe('false') + expect(wrapper.get('.performance-benchmarks__results').attributes('style')).toContain( + 'display: none' + ) + await resultsToggle.trigger('click') + expect( + wrapper + .get('.performance-benchmarks__column:nth-child(3) .performance-benchmarks__run') + .exists() + ).toBe(true) + expect(wrapper.find('.performance-benchmarks__drop-zone').text()).toBe( + 'Drop a workflow .json file here, or click to browse' + ) + expect(wrapper.find('.performance-benchmarks__run').text()).toBe('Run') + expect(wrapper.find('.performance-benchmarks__logs').text()).toBe( + 'Instance logs will appear here.' + ) + await logsToggle.trigger('click') + expect(logsToggle.attributes('aria-expanded')).toBe('false') + expect(wrapper.get('.performance-benchmarks__logs').attributes('style')).toContain( + 'display: none' + ) + await logsToggle.trigger('click') + const instanceSelect = wrapper.get('.performance-benchmarks__instance-select button') + expect(instanceSelect.attributes()).toMatchObject({ + role: 'combobox', + 'aria-label': 'Select an instance', + 'aria-expanded': 'false' + }) + expect(instanceSelect.text()).toBe('Select an instance') + await instanceSelect.trigger('click') + await flushPromises() + expect( + Array.from(document.querySelectorAll('.ui-select-option-label')).map( + (option) => option.textContent + ) + ).toEqual(['Workspace Install', 'Legacy Install', 'Missing Install']) + expect( + Array.from(document.querySelectorAll('.ui-select-option-desc')).map( + (option) => option.textContent + ) + ).toEqual(['Standalone · 0.3.50', 'Standalone', 'Standalone']) + expect(wrapper.text()).not.toContain('Other Workspace Install') + expect(wrapper.find('.performance-benchmarks__description').text()).toBe( + 'Run performance measurements against your ComfyUI instances.' + ) + expect(wrapper.find('[data-testid="chooser-view"]').exists()).toBe(false) + ;(document.querySelectorAll('.ui-select-option')[0] as HTMLElement).click() + await flushPromises() + expect(instanceSelect.text()).toBe('Workspace Install') + + const api = ( + window as unknown as { + api: { + openInstancePicker: ReturnType + importBenchmarkWorkflow: ReturnType + deleteBenchmarkWorkflow: ReturnType + runBenchmarkWorkflow: ReturnType + getPathForFile: ReturnType + runAction: ReturnType + stopComfyUI: ReturnType + cancelOperation: ReturnType + onInstanceStarted: ReturnType + onInstanceStopped: ReturnType + onComfyOutput: ReturnType + } + } + ).api + await wrapper.get('.performance-benchmarks__drop-content').trigger('click') + await flushPromises() + expect(api.importBenchmarkWorkflow).toHaveBeenCalledWith(undefined) + expect(wrapper.get('.performance-benchmarks__workflow-file').text()).toContain( + 'cat-workflow.json' + ) + expect(wrapper.get('.performance-benchmarks__workflow-file').text()).toContain( + 'C:\\ComfyUI\\benchmarks\\20260907225500\\cat-workflow.json' + ) + expect(wrapper.get('.performance-benchmarks__drop-zone').text()).not.toContain( + 'Drop a workflow .json file here, or click to browse' + ) + + api.importBenchmarkWorkflow.mockResolvedValueOnce({ + ok: true, + filePath: 'C:\\ComfyUI\\benchmarks\\20260907225600\\cat-workflow.json' + }) + const droppedFile = new File(['{}'], 'dropped.json', { type: 'application/json' }) + await wrapper.get('.performance-benchmarks__drop-zone').trigger('drop', { + dataTransfer: { files: [droppedFile] } + }) + await flushPromises() + expect(api.getPathForFile).toHaveBeenCalledWith(droppedFile) + expect(api.importBenchmarkWorkflow).toHaveBeenLastCalledWith('C:\\incoming\\dropped.json') + expect(wrapper.get('.performance-benchmarks__workflow-file').text()).toContain( + 'C:\\ComfyUI\\benchmarks\\20260907225600\\cat-workflow.json' + ) + + await wrapper.get('.performance-benchmarks__run').trigger('click') + await flushPromises() + expect(api.runAction).toHaveBeenCalledWith('workspace-install', 'launch', { + launchModeOverride: 'console', + autoPortOnConflict: true, + sessionIdOverride: 'benchmark:workspace-install' + }) + expect(api.runBenchmarkWorkflow).toHaveBeenCalledWith( + 'benchmark:workspace-install', + 'C:\\ComfyUI\\benchmarks\\20260907225600\\cat-workflow.json', + 5 + ) + expect(wrapper.get('.performance-benchmarks__logs').text()).toContain( + 'Finished 5 measured runs (0 unsuccessful). Final response saved to ' + ) + expect(wrapper.get('.performance-benchmarks__logs').text()).toContain( + 'C:\\ComfyUI\\benchmarks\\20260907225600\\results.json' + ) + const results = wrapper.get('.performance-benchmarks__results').text() + expect(results).toContain('Fastest job') + expect(results).toContain('1.250 s') + expect(results).toContain('Slowest job') + expect(results).toContain('2.750 s') + expect(results).not.toContain('prompt-3') + expect(results).not.toContain('prompt-7') + expect(results).toContain('Average job duration') + expect(results).toContain('2.000 s') + expect(results).toContain('Median job duration') + expect(results).toContain('1.875 s') + expect(results).toContain('Hardware') + expect(results).toContain('Compute device') + expect(results).toContain('NVIDIA GeForce RTX 4090') + expect(results).toContain('VRAM') + expect(results).toContain('24576 MB') + expect(results).toContain('System information') + expect(results).toContain('Microsoft Windows 11 Pro 10.0.26200') + expect(results).toContain('AMD Ryzen 9 7950X') + expect(results).toContain('Logical CPU cores') + expect(results).toContain('32') + expect(results).toContain('System memory') + expect(results).toContain('64 GB') + expect(results).toContain('GPU driver') + expect(results).toContain('580.88') + const outputCallback = api.onComfyOutput.mock.calls[0]![0] as (data: { + installationId: string + text: string + }) => void + outputCallback({ installationId: 'benchmark:workspace-install', text: 'ComfyUI is ready\n' }) + await flushPromises() + expect(wrapper.get('.performance-benchmarks__logs').text()).toContain('ComfyUI is ready') + expect(wrapper.get('.performance-benchmarks__stop').attributes('disabled')).toBe('') + expect(api.stopComfyUI).toHaveBeenCalledWith('benchmark:workspace-install') + expect(api.cancelOperation).not.toHaveBeenCalled() + + await wrapper.get('.performance-benchmarks__delete-workflow').trigger('click') + await flushPromises() + expect(api.deleteBenchmarkWorkflow).toHaveBeenCalledWith( + 'C:\\ComfyUI\\benchmarks\\20260907225600\\cat-workflow.json' + ) + expect(wrapper.find('.performance-benchmarks__workflow-file').exists()).toBe(false) + expect(wrapper.get('.performance-benchmarks__drop-zone').text()).toContain( + 'Drop a workflow .json file here, or click to browse' + ) + + await wrapper.get('[data-testid="devplatform-workspace-selector"]').trigger('click') + await wrapper.get('[data-testid="devplatform-workspace-unmanaged"]').trigger('click') + await flushPromises() + + expect(instanceSelect.text()).toBe('Select an instance') + await instanceSelect.trigger('click') + await flushPromises() + expect( + Array.from(document.querySelectorAll('.ui-select-option-label')).map( + (option) => option.textContent + ) + ).toEqual(['Unmanaged Install']) + ;(document.querySelector('.ui-select-option') as HTMLElement).click() + await flushPromises() + }) + it('opens the new-install takeover above the chooser body when show-new-install fires', async () => { // Flow modals are Tier 3 takeover overlays. The chooser stays // mounted underneath the takeover, so dismissing the takeover @@ -430,6 +849,114 @@ describe('PanelApp', () => { }) }) + it('allows benchmarking an instance started outside benchmarks', async () => { + mockState.installations = [ + { + ...SAMPLE_INSTALL, + id: 'workspace-install', + name: 'Workspace Install', + sourceId: 'standalone', + status: 'installed', + workspaceId: 'workspace-1' + } + ] + const api = ( + window as unknown as { + api: { getRunningInstances: ReturnType } + } + ).api + api.getRunningInstances.mockResolvedValueOnce([ + { + installationId: 'workspace-install', + installationName: 'Workspace Install', + mode: 'window' + } + ]) + window.history.replaceState({}, '', '/?panel=performance-benchmarks&firstUseCompleted=true') + + const wrapper = mountPanel() + await flushPromises() + + await wrapper.get('.performance-benchmarks__drop-content').trigger('click') + await wrapper.get('.performance-benchmarks__instance-select button').trigger('click') + await flushPromises() + ;(document.querySelector('.ui-select-option') as HTMLElement).click() + await flushPromises() + expect(wrapper.get('.performance-benchmarks__run').attributes('disabled')).toBeUndefined() + }) + + it('allows a crashed benchmark session to be stopped and cleared manually', async () => { + mockState.installations = [ + { + ...SAMPLE_INSTALL, + id: 'workspace-install', + name: 'Workspace Install', + sourceId: 'standalone', + status: 'installed', + workspaceId: 'workspace-1' + } + ] + const api = ( + window as unknown as { + api: { + runBenchmarkWorkflow: ReturnType + stopComfyUI: ReturnType + onComfyExited: ReturnType + } + } + ).api + let resolveBenchmark!: (result: { + ok: boolean + submitted: number + preparationRuns: number + totalSubmitted: number + message: string + }) => void + api.runBenchmarkWorkflow.mockImplementationOnce( + () => new Promise((resolve) => (resolveBenchmark = resolve)) + ) + window.history.replaceState({}, '', '/?panel=performance-benchmarks&firstUseCompleted=true') + const wrapper = mountPanel() + await flushPromises() + + await wrapper.get('.performance-benchmarks__drop-content').trigger('click') + await wrapper.get('.performance-benchmarks__instance-select button').trigger('click') + await flushPromises() + ;(document.querySelector('.ui-select-option') as HTMLElement).click() + await flushPromises() + await wrapper.get('.performance-benchmarks__run').trigger('click') + await flushPromises() + + const exitedCallback = api.onComfyExited.mock.calls[0]![0] as (data: { + installationId: string + installationName: string + crashed: boolean + exitCode: number + }) => void + exitedCallback({ + installationId: 'benchmark:workspace-install', + installationName: 'Workspace Install', + crashed: true, + exitCode: 1 + }) + await flushPromises() + + const stopButton = wrapper.get('.performance-benchmarks__stop') + expect(stopButton.attributes('disabled')).toBeUndefined() + await stopButton.trigger('click') + await flushPromises() + expect(api.stopComfyUI).toHaveBeenCalledWith('benchmark:workspace-install') + expect(stopButton.attributes('disabled')).toBe('') + resolveBenchmark({ + ok: false, + submitted: 0, + preparationRuns: 0, + totalSubmitted: 0, + message: 'The benchmark instance exited.' + }) + await flushPromises() + }) + it('returns to the underlying body when a takeover emits close', async () => { window.history.replaceState({}, '', '/?panel=new-install&firstUseCompleted=true') const wrapper = mountPanel() diff --git a/src/renderer/src/panel/PanelApp.vue b/src/renderer/src/panel/PanelApp.vue index 944b90385..37f7003a8 100644 --- a/src/renderer/src/panel/PanelApp.vue +++ b/src/renderer/src/panel/PanelApp.vue @@ -8,6 +8,7 @@ import FeedbackModal from '../components/FeedbackModal.vue' import AnnouncementModal from '../components/AnnouncementModal.vue' import ComfyLifecycleView from './ComfyLifecycleView.vue' import ChooserView from '../views/ChooserView.vue' +import PerformanceBenchmarksView from '../views/PerformanceBenchmarksView.vue' import InstallWizardModal from '../views/InstallWizardModal.vue' import TrackModal from '../views/TrackModal.vue' import LoadSnapshotModal from '../views/LoadSnapshotModal.vue' @@ -603,6 +604,13 @@ onUnmounted(() => { @show-progress="handleShowProgress" /> + +
+ +
@@ -721,7 +729,8 @@ body.panel-overlay-mode .panel-shell { * so its grid can scroll vertically) — negate the panel-content * gutter for those branches. */ .panel-content:has(.panel-comfy-lifecycle), -.panel-content:has(.panel-chooser) { +.panel-content:has(.panel-chooser), +.panel-content:has(.panel-performance-benchmarks) { padding: 0; } @@ -737,7 +746,8 @@ body.panel-overlay-mode .panel-shell { } .panel-comfy-lifecycle, -.panel-chooser { +.panel-chooser, +.panel-performance-benchmarks { flex: 1; min-height: 0; display: flex; diff --git a/src/renderer/src/panel/usePanelOverlays.ts b/src/renderer/src/panel/usePanelOverlays.ts index e695a3e53..d930629e0 100644 --- a/src/renderer/src/panel/usePanelOverlays.ts +++ b/src/renderer/src/panel/usePanelOverlays.ts @@ -20,6 +20,7 @@ export type PanelKey = | 'comfy' | 'comfy-lifecycle' | 'chooser' + | 'performance-benchmarks' | 'feedback' | 'new-install' | 'track' @@ -44,6 +45,7 @@ const VALID_PANELS: ReadonlySet = new Set([ 'comfy', 'comfy-lifecycle', 'chooser', + 'performance-benchmarks', 'feedback', 'new-install', 'track', diff --git a/src/renderer/src/views/ChooserView.test.ts b/src/renderer/src/views/ChooserView.test.ts index 203a1820e..65dab8c64 100644 --- a/src/renderer/src/views/ChooserView.test.ts +++ b/src/renderer/src/views/ChooserView.test.ts @@ -673,17 +673,17 @@ describe('ChooserView', () => { expect(toolbarChildren).toHaveLength(1) expect(toolbarChildren[0]!.classList.contains('chooser-search')).toBe(true) const workspaceBar = wrapper.get('.chooser-workspace-bar') - const controls = wrapper.get('.chooser-workspace-controls') + const controls = wrapper.get('.workspace-selector-bar__controls') const selector = wrapper.get('[data-testid="devplatform-workspace-selector"]') - const refresh = wrapper.get('[data-testid="chooser-workspace-refresh"]') + const refresh = wrapper.get('[data-testid="workspace-selector-refresh"]') expect(controls.element.parentElement).toBe(workspaceBar.element) - expect(selector.element.closest('.chooser-workspace-controls')).toBe(controls.element) + expect(selector.element.closest('.workspace-selector-bar__controls')).toBe(controls.element) expect( selector.element.compareDocumentPosition(refresh.element) & Node.DOCUMENT_POSITION_FOLLOWING ).not.toBe(0) - expect(workspaceBar.get('.chooser-workspace-count').text()).toBe('INSTANCES0') + expect(workspaceBar.get('.workspace-selector-bar__trailing').text()).toBe('INSTANCES0') expect(workspaceBar.element.lastElementChild).toBe( - workspaceBar.get('.chooser-workspace-count').element + workspaceBar.get('.workspace-selector-bar__trailing').element ) }) @@ -822,7 +822,7 @@ describe('ChooserView', () => { api.comfybuilder.listWorkspaces.mockClear() api.comfybuilder.listBuilds.mockClear() - await wrapper.find('[data-testid="chooser-workspace-refresh"]').trigger('click') + await wrapper.find('[data-testid="workspace-selector-refresh"]').trigger('click') await flushPromises() expect(api.comfybuilder.listWorkspaces).toHaveBeenCalledOnce() diff --git a/src/renderer/src/views/ChooserView.vue b/src/renderer/src/views/ChooserView.vue index cbc3513e4..bd45724a4 100644 --- a/src/renderer/src/views/ChooserView.vue +++ b/src/renderer/src/views/ChooserView.vue @@ -1,15 +1,16 @@ + + + + diff --git a/src/renderer/src/views/chooser/ChooserInstallTile.vue b/src/renderer/src/views/chooser/ChooserInstallTile.vue index eab8a125d..63a2cc642 100644 --- a/src/renderer/src/views/chooser/ChooserInstallTile.vue +++ b/src/renderer/src/views/chooser/ChooserInstallTile.vue @@ -22,13 +22,20 @@ interface Props { installation: Installation showFreeRunsPill?: boolean showWhyCloud?: boolean + /** Override runtime lifecycle presentation for surfaces that own separate sessions. */ + lifecycleStatus?: 'idle' | 'launching' | 'running' | 'stopping' | 'error' /** True when REQUIRES_STOPPED actions (update / migrate / restore / delete) are gated. */ isStoppedActionGated: boolean /** True while Desktop captures this instance and creates its workspace draft. */ isPromotingToWorkspace?: boolean + /** Hide the card's context/kebab menu while keeping inline action pills enabled. */ + showMenu?: boolean } -const props = defineProps() +const props = withDefaults(defineProps(), { + lifecycleStatus: undefined, + showMenu: true +}) const emit = defineEmits<{ pick: [installation: Installation] @@ -45,9 +52,21 @@ const sessionStore = useSessionStore() const inst = computed(() => props.installation) -const isRunning = computed(() => sessionStore.isRunning(inst.value.id)) -const isLaunching = computed(() => sessionStore.isLaunching(inst.value.id)) -const isStopping = computed(() => sessionStore.isStopping(inst.value.id)) +const isRunning = computed(() => + props.lifecycleStatus == null + ? sessionStore.isRunning(inst.value.id) + : props.lifecycleStatus === 'running' +) +const isLaunching = computed(() => + props.lifecycleStatus == null + ? sessionStore.isLaunching(inst.value.id) + : props.lifecycleStatus === 'launching' +) +const isStopping = computed(() => + props.lifecycleStatus == null + ? sessionStore.isStopping(inst.value.id) + : props.lifecycleStatus === 'stopping' +) /* A managed update flips the record to status 'updating'; a standalone * update never touches the record and is only visible through main's * operation broadcast. Key on both so the tile reports "Updating" the same @@ -57,7 +76,11 @@ const isUpdating = computed(() => { const op = sessionStore.operationInstances.get(inst.value.id) return op != null && progressOpKindForActionId(op.actionId) === 'update' }) -const hasError = computed(() => sessionStore.errorInstances.has(inst.value.id)) +const hasError = computed(() => + props.lifecycleStatus == null + ? sessionStore.errorInstances.has(inst.value.id) + : props.lifecycleStatus === 'error' +) /* Backend-flagged problem states (failed install, interrupted delete, missing * install folder) carry a `danger` statusTag. Surface it as a static red pill — @@ -176,7 +199,7 @@ function handleClick(): void { } function handleContextMenu(event: MouseEvent): void { - if (isUpdating.value) return + if (!props.showMenu || isUpdating.value) return emit('open-card-menu', event, inst.value) } @@ -257,7 +280,7 @@ function triggerInstallAction(action: 'update' | 'migrate'): void { {{ dangerTag.label }}