diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 56d5a13b2c26..e385efb4164a 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -390,7 +390,7 @@ extension CMUXCLI { noOpCommand: String = "echo '{}'", failOpen: Bool = false ) -> String { - guard case .pinned(let marker) = def.dispatch else { + guard case .pinned = def.dispatch else { return agentHookShellCommand(command, for: def, noOpCommand: noOpCommand) } let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 502973552376..852a8b77c889 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27878,7 +27878,6 @@ struct CMUXCLI { let workspaceId = resolvedTarget.workspaceId let resolvedSurface = resolvedTarget let surfaceId = resolvedSurface.surfaceId - let claudePid = localClaudePID(mapped: mappedSession) // Detected once (bounded process-ancestry walk) and reused for // both the suppression gate and the notify payload's subagent // tag, which stays accurate even when suppression is off. @@ -39498,7 +39497,6 @@ export default CMUXSessionRestore; socketPassword: String? = nil, telemetry: CLISocketSentryTelemetry ) throws { - let invocationStartedAt = ProcessInfo.processInfo.systemUptime _ = telemetry let source = optionValue(commandArgs, name: "--source") ?? "" guard !source.isEmpty else { diff --git a/Packages/macOS/CmuxCloudMachines/README.md b/Packages/macOS/CmuxCloudMachines/README.md index d7ee42cbb3ac..db7d462f0043 100644 --- a/Packages/macOS/CmuxCloudMachines/README.md +++ b/Packages/macOS/CmuxCloudMachines/README.md @@ -17,3 +17,13 @@ let coordinator = CloudWorkspaceCoordinator( ) let workspaceID = try await coordinator.createOnDefaultMachine(focus: true) ``` + +`CloudMachineResourcePresentation` validates and formats CPU, memory, and disk samples independently of app/provider types. The app maps its immutable machine snapshot at the UI boundary; missing and sleeping samples remain explicit. Localized labels use the host application's catalog. + +```swift +let resources = CloudMachineResourcePresentation( + availability: .awake, cpuPercent: 25, + memoryUsedMb: 2048, memoryTotalMb: 4096 +) +// resources.memory.percent == 50 +``` diff --git a/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineResourcePresentation.swift b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineResourcePresentation.swift new file mode 100644 index 000000000000..8c3982817826 --- /dev/null +++ b/Packages/macOS/CmuxCloudMachines/Sources/CmuxCloudMachines/CloudMachineResourcePresentation.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Pure presentation of the latest stats snapshot, shared by the row and its tooltip. +public struct CloudMachineResourcePresentation: Sendable { + /// Whether the latest sample can be presented as live usage. + public enum Availability: Equatable, Sendable { + /// The machine is awake and supports resource statistics. + case awake + /// The machine is sleeping, so prior readings are unavailable. + case asleep + /// A supported, current sample is not available. + case unavailable + } + + /// One localized resource label, optional percentage, and accessible detail. + public struct Reading: Sendable { + /// The localized name of this resource. + public let label: String + /// A validated utilization percentage, or nil for an unavailable reading. + public let percent: Double? + /// Localized detail suitable for a tooltip or accessibility label. + public let detail: String + + /// A whole percentage or the localized missing-value placeholder. + public var value: String { + guard let percent else { + return String(localized: "cloudTree.resources.missing", defaultValue: "—") + } + return (percent / 100).formatted(.percent.precision(.fractionLength(0))) + } + } + + /// Current CPU utilization. + public let cpu: Reading + /// Current memory utilization and reported capacity. + public let memory: Reading + /// Current root disk utilization and reported capacity. + public let disk: Reading + + /// The three resource details in display order, separated by newlines. + public var summary: String { [cpu.detail, memory.detail, disk.detail].joined(separator: "\n") } + + /// Presents advisory resource readings without depending on app or provider types. + /// + /// Localized text uses the host application's string catalog. + /// + /// ```swift + /// let resources = CloudMachineResourcePresentation(availability: .awake, cpuPercent: 25) + /// // resources.cpu.percent == 25 + /// ``` + /// + /// - Parameters: + /// - availability: Whether the sample is live, sleeping, or unavailable. + /// - cpuPercent: CPU utilization in the inclusive range 0 through 100. + /// - memoryUsedMb: Used memory in MiB, when sampled. + /// - memoryTotalMb: Provisioned memory in MiB, when known. + /// - diskUsedMb: Used root filesystem space in MiB, when sampled. + /// - diskTotalMb: Root filesystem capacity in MiB, when known. + public init( + availability: Availability, + cpuPercent: Double? = nil, + memoryUsedMb: Int? = nil, + memoryTotalMb: Int? = nil, + diskUsedMb: Int? = nil, + diskTotalMb: Int? = nil + ) { + let available = availability == .awake + let unavailable = availability == .asleep + ? String(localized: "cloudTree.resources.asleep", defaultValue: "Asleep") + : String(localized: "cloudTree.resources.unavailable", defaultValue: "Unavailable") + let cpuLabel = String(localized: "machines.stats.cpu", defaultValue: "CPU") + let cpuPercent = available ? cpuPercent.flatMap(Self.validCPU) : nil + cpu = Reading( + label: cpuLabel, + percent: cpuPercent, + detail: cpuPercent.map { + String(format: String(localized: "cloudTree.stats.cpu", defaultValue: "CPU %d%%"), Int($0.rounded())) + } ?? "\(cpuLabel): \(unavailable)" + ) + memory = Self.capacity( + label: String(localized: "cloudTree.resources.ram", defaultValue: "RAM"), + used: available ? memoryUsedMb : nil, + total: available ? memoryTotalMb : nil, + format: String(localized: "cloudTree.stats.memory", defaultValue: "Mem %@/%@ GB"), + unavailable: unavailable + ) + disk = Self.capacity( + label: String(localized: "machines.stats.disk", defaultValue: "Disk"), + used: available ? diskUsedMb : nil, + total: available ? diskTotalMb : nil, + format: String(localized: "cloudTree.stats.disk", defaultValue: "Disk %@/%@ GB"), + unavailable: unavailable + ) + } + + private static func validCPU(_ percent: Double) -> Double? { + guard percent.isFinite, (0...100).contains(percent) else { return nil } + return percent + } + + private static func capacity(label: String, used: Int?, total: Int?, format: String, unavailable: String) -> Reading { + guard let used, let total, used >= 0, total > 0 else { + return Reading(label: label, percent: nil, detail: "\(label): \(unavailable)") + } + // Separate OS counters may straddle an update. Keep the percentage within capacity + // while preserving the actual reported amounts in the detail. + let percent = min(100, Double(used) / Double(total) * 100) + let value = Reading(label: label, percent: percent, detail: "").value + return Reading( + label: label, + percent: percent, + detail: "\(String(format: format, gb(used), gb(total))) (\(value))" + ) + } + + private static func gb(_ mb: Int) -> String { + (Double(mb) / 1024).formatted(.number.precision(.fractionLength(0...1))) + } +} diff --git a/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineResourcePresentationTests.swift b/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineResourcePresentationTests.swift new file mode 100644 index 000000000000..af2f62ff1173 --- /dev/null +++ b/Packages/macOS/CmuxCloudMachines/Tests/CmuxCloudMachinesTests/CloudMachineResourcePresentationTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CmuxCloudMachines + +@Suite("Cloud machine resource readings") +struct CloudMachineResourcePresentationTests { + @Test func awakeReadingsDescribeUsageRatherThanProvisionedCapacity() { + let result = CloudMachineResourcePresentation( + availability: .awake, cpuPercent: 9.4, + memoryUsedMb: 2048, memoryTotalMb: 4096, + diskUsedMb: 3072, diskTotalMb: 4096 + ) + #expect(result.cpu.percent == 9.4) + #expect(result.memory.percent == 50) + #expect(result.disk.percent == 75) + #expect(result.cpu.value == (0.094).formatted(.percent.precision(.fractionLength(0)))) + #expect(result.memory.detail.contains("2/4")) + #expect(result.disk.detail.contains("3/4")) + } + + @Test func zeroIsARealReadingAndMissingSamplesKeepTheirLabels() { + let result = CloudMachineResourcePresentation( + availability: .awake, cpuPercent: 0, memoryTotalMb: 4096, + diskUsedMb: 0, diskTotalMb: 4096 + ) + #expect(result.cpu.percent == 0) + #expect(result.memory.percent == nil) + #expect(result.disk.percent == 0) + #expect(result.cpu.value != result.memory.value) + #expect(!result.memory.label.isEmpty) + #expect(!result.memory.detail.isEmpty) + } + + @Test(arguments: [CloudMachineResourcePresentation.Availability.asleep, .unavailable]) + func inactiveSamplesNeverPresentOldValuesAsLive(availability: CloudMachineResourcePresentation.Availability) { + let result = CloudMachineResourcePresentation( + availability: availability, cpuPercent: 83, + memoryUsedMb: 2048, memoryTotalMb: 4096, + diskUsedMb: 3072, diskTotalMb: 4096 + ) + #expect(result.cpu.percent == nil) + #expect(result.memory.percent == nil) + #expect(result.disk.percent == nil) + } + + @Test(arguments: [Double.nan, .infinity, -.infinity, -1, 101, .greatestFiniteMagnitude]) + func malformedCPUIsUnavailableWithoutTrapping(cpu: Double) { + let result = CloudMachineResourcePresentation(availability: .awake, cpuPercent: cpu) + #expect(result.cpu.percent == nil) + #expect(!result.cpu.value.isEmpty) + } + + @Test(arguments: [(0, 0), (2, -1), (-1, 1024)]) + func invalidCapacityIsUnavailable(counts: (Int, Int)) { + let result = CloudMachineResourcePresentation( + availability: .awake, memoryUsedMb: counts.0, memoryTotalMb: counts.1, + diskUsedMb: counts.0, diskTotalMb: counts.1 + ) + #expect(result.memory.percent == nil) + #expect(result.disk.percent == nil) + } + + @Test func capacityCounterRacesStayBounded() { + let result = CloudMachineResourcePresentation( + availability: .awake, memoryUsedMb: 4097, memoryTotalMb: 4096, + diskUsedMb: .max, diskTotalMb: 4096 + ) + #expect(result.memory.percent == 100) + #expect(result.disk.percent == 100) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c5777bdf7d25..f57cad37d848 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -237483,6 +237483,108 @@ "state": "translated", "value": "CPU" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ЦП" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "ЦП" + } } } }, @@ -396990,128 +397092,128 @@ } } }, - "sidebar.cloudWorkspace.label" : { - "comment" : "Tooltip and workspace-row accessibility label for a Cloud-bound workspace. The placeholder is the authoritative machine identifier.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cloud workspace on %@" + "sidebar.cloudWorkspace.label": { + "comment": "Tooltip and workspace-row accessibility label for a Cloud-bound workspace. The placeholder is the authoritative machine identifier.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud workspace on %@" } }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مساحة عمل سحابية على %@" + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل سحابية على %@" } }, - "bs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cloud radni prostor na %@" + "bs": { + "stringUnit": { + "state": "translated", + "value": "Cloud radni prostor na %@" } }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cloud-arbejdsområde på %@" + "da": { + "stringUnit": { + "state": "translated", + "value": "Cloud-arbejdsområde på %@" } }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cloud-Arbeitsbereich auf %@" + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Arbeitsbereich auf %@" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Espacio de trabajo en la nube en %@" + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo en la nube en %@" } }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Espace de travail Cloud sur %@" + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail Cloud sur %@" } }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Workspace Cloud su %@" + "it": { + "stringUnit": { + "state": "translated", + "value": "Workspace Cloud su %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "クラウドワークスペース(%@)" + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドワークスペース(%@)" } }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "កន្លែងធ្វើការលើ Cloud នៅលើ %@" + "km": { + "stringUnit": { + "state": "translated", + "value": "កន្លែងធ្វើការលើ Cloud នៅលើ %@" } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@의 Cloud 워크스페이스" + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 Cloud 워크스페이스" } }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cloud-arbeidsområde på %@" + "nb": { + "stringUnit": { + "state": "translated", + "value": "Cloud-arbeidsområde på %@" } }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obszar roboczy Cloud na %@" + "pl": { + "stringUnit": { + "state": "translated", + "value": "Obszar roboczy Cloud na %@" } }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Workspace na nuvem em %@" + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Workspace na nuvem em %@" } }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Облачное рабочее пространство на %@" + "ru": { + "stringUnit": { + "state": "translated", + "value": "Облачное рабочее пространство на %@" } }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เวิร์กสเปซบนคลาวด์ที่ %@" + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซบนคลาวด์ที่ %@" } }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ üzerindeki Cloud çalışma alanı" + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ üzerindeki Cloud çalışma alanı" } }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Хмарний робочий простір на %@" + "uk": { + "stringUnit": { + "state": "translated", + "value": "Хмарний робочий простір на %@" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 上的云工作区" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 上的云工作区" } }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 上的雲端工作區" + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 上的雲端工作區" } } } @@ -523631,6 +523733,631 @@ } } } + }, + "cloudTree.resources.missing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "—" + } + } + } + }, + "cloudTree.resources.asleep": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asleep" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Ruhezustand" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En veille" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "في وضع السكون" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En reposo" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "休眠中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "休眠中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "절전 상태" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スリープ中" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "U stanju mirovanja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "I dvale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In sospensione" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងដេក" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "I dvale" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uśpiona" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Em suspensão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В спящем режиме" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พักการทำงาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uykuda" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У режимі сну" + } + } + } + }, + "cloudTree.resources.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unavailable" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht verfügbar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Indisponible" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير متاح" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No disponible" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法取得" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不可用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 불가" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "取得不可" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nedostupno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ikke tilgængelig" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non disponibile" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចប្រើបាន" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Utilgjengelig" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Niedostępne" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Indisponível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недоступно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พร้อมใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanılamıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Недоступно" + } + } + } + }, + "cloudTree.resources.sampled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sampled %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erfasst %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mesuré le %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم القياس %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Medido el %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取樣時間 %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "采样时间 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "측정 시간 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "測定日時 %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uzorkovano %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Målt %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Campionato %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានវាស់ %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Målt %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pomiar: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Amostra de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Замер: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วัดเมื่อ %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ölçüm: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Виміряно %@" + } + } + } + }, + "cloudTree.resources.ram": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "記憶體" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ОЗУ" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "ОЗП" + } + } + } } }, "version": "1.0" diff --git a/Sources/Cloud/CloudMachineResourcePresentation+MachineSnapshot.swift b/Sources/Cloud/CloudMachineResourcePresentation+MachineSnapshot.swift new file mode 100644 index 000000000000..22417269953d --- /dev/null +++ b/Sources/Cloud/CloudMachineResourcePresentation+MachineSnapshot.swift @@ -0,0 +1,18 @@ +import CmuxCloudMachines + +extension CloudMachineResourcePresentation { + /// Maps the app's immutable machine snapshot to the package's resource presentation. + init(machine: MachineSnapshot) { + let stats = machine.stats + let availability: Availability = machine.capabilities.stats && stats?.state == .awake + ? .awake : stats?.state == .asleep ? .asleep : .unavailable + self.init( + availability: availability, + cpuPercent: stats?.cpuPercent, + memoryUsedMb: stats?.memoryUsedMb, + memoryTotalMb: stats?.memoryTotalMb, + diskUsedMb: stats?.diskUsedMb, + diskTotalMb: stats?.diskTotalMb + ) + } +} diff --git a/Sources/Cloud/CloudOperationRecorder.swift b/Sources/Cloud/CloudOperationRecorder.swift index 534a07ee1388..830a48868676 100644 --- a/Sources/Cloud/CloudOperationRecorder.swift +++ b/Sources/Cloud/CloudOperationRecorder.swift @@ -115,7 +115,7 @@ final class CloudOperationRecorder { trim() } - nonisolated func perform( + func perform( _ operation: CloudOperationKind, foreground: Bool = true, file: StaticString = #fileID, line: UInt = #line, isolation: isolated (any Actor)? = #isolation, _ work: () async throws -> T diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 0c26caf9504e..142308f29119 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -82,10 +82,10 @@ final class CloudTreeCellView: NSTableCellView { buttons.isHidden = false buttons.alphaValue = hovered ? 1 : 0 buttonsLeadingConstraint?.isActive = true - // Two-line machine cards pin the buttons to the name line; every - // other row centers them vertically. - let pinToNameLine = node.isMachineRow && style.machineRowLayout == .twoLine - buttonsTopConstraint?.constant = style.machineVerticalPadding + // Cloud resources sit below the name; keep hover buttons on its line. + // Local and pending rows retain their preset alignment. + let pinToNameLine = node.isMachineRow && (style.machineRowLayout == .twoLine || node.structureTag == "machine") + buttonsTopConstraint?.constant = style.machineVerticalPadding + (style.machineBand ? 4 : 0) buttonsTopConstraint?.isActive = pinToNameLine buttonsCenterConstraint?.isActive = !pinToNameLine } else { @@ -93,7 +93,7 @@ final class CloudTreeCellView: NSTableCellView { buttonsLeadingConstraint?.isActive = false } if case .machine(let machine, _) = node.kind { - toolTip = [machine.displayName, machine.activityLabel, machine.image].joined(separator: "\n") + toolTip = CloudTreeMachineRowContent(machine: machine).toolTip } else if case .pendingMachine(let operation) = node.kind { // The failure's first line rides along so a red row explains itself on hover. toolTip = operation.summaryLine @@ -106,7 +106,11 @@ final class CloudTreeCellView: NSTableCellView { } else { toolTip = nil } - setAccessibilityLabel(node.searchableTitle) + if case .machine(let machine, _) = node.kind { + setAccessibilityLabel(CloudTreeMachineRowContent(machine: machine).accessibilityLabel) + } else { + setAccessibilityLabel(node.searchableTitle) + } } private func makeButtonsHost() -> NSHostingView { diff --git a/Sources/Cloud/CloudTreeMachineResourceView.swift b/Sources/Cloud/CloudTreeMachineResourceView.swift new file mode 100644 index 000000000000..8aa0d2592acc --- /dev/null +++ b/Sources/Cloud/CloudTreeMachineResourceView.swift @@ -0,0 +1,36 @@ +import CmuxCloudMachines +import CmuxFoundation +import SwiftUI + +/// Equal-width columns prevent changing names or readings from shifting the metrics. +struct CloudTreeMachineResourceView: View { + let metrics: CloudMachineResourcePresentation + let style: CloudTreeStyle + + var body: some View { + HStack(spacing: 8) { + column(metrics.cpu) + column(metrics.memory) + column(metrics.disk) + } + .frame(height: style.machineResourceHeight) + } + + private func column(_ reading: CloudMachineResourcePresentation.Reading) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(reading.label) + .cmuxFont(size: style.detailSize, design: style.fontDesign) + .foregroundStyle(.secondary) + .frame(height: style.detailSize + 2) + Text(reading.value) + .cmuxFont(size: style.machineNameSize, weight: .semibold, design: style.fontDesign, monospacedDigit: true) + .foregroundStyle(reading.percent == nil ? .secondary : .primary) + .minimumScaleFactor(0.8) + .frame(height: style.machineNameLineHeight) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(reading.detail) + } +} diff --git a/Sources/Cloud/CloudTreeMachineRowContent.swift b/Sources/Cloud/CloudTreeMachineRowContent.swift new file mode 100644 index 000000000000..7a14c0b7be05 --- /dev/null +++ b/Sources/Cloud/CloudTreeMachineRowContent.swift @@ -0,0 +1,144 @@ +import CmuxCloudMachines +import CmuxFoundation +import SwiftUI + +/// Resource readings stay below the name, aligned across machines at every width. +/// This view receives only an immutable snapshot; the panel owns stats refreshes. +struct CloudTreeMachineRowContent: View { + let machine: MachineSnapshot + var style: CloudTreeStyle = CloudTreeStyleStore.current + + var body: some View { + CloudTreeMachineBand(style: style) { + HStack(alignment: .top, spacing: CloudTreeRowGrid.dotGap) { + Image(systemName: machine.freeAccess == .expired ? "lock.fill" : "cloud") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: CloudTreeRowGrid.dotSlot, height: style.machineNameLineHeight) + VStack(alignment: .leading, spacing: CloudTreeRowGrid.machineLineSpacing) { + HStack(alignment: .firstTextBaseline, spacing: CloudTreeRowGrid.dotGap) { + Text(machine.displayName) + .cmuxFont(size: style.machineNameSize, weight: .medium, design: style.fontDesign) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + if machine.isDefault { + Image(systemName: "star.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .help(String(localized: "machines.row.default.help", defaultValue: "Default machine for New Cloud Workspace")) + } + if let fact = inlineFact { + Text(fact) + .cmuxFont(size: style.detailSize, design: style.fontDesign) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(height: style.machineNameLineHeight) + if style.showsMachineStats { + CloudTreeMachineResourceView(metrics: CloudMachineResourcePresentation(machine: machine), style: style) + .padding(.top, 3) + } + if style.machineRowLayout == .twoLine { + Text(subtitle) + .cmuxFont(size: style.detailSize, design: style.fontDesign) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: style.machineSubtitleLineHeight) + } + } + } + .padding(.vertical, style.machineVerticalPadding) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } + + /// Combines this machine's identity, activity, and resource readings for assistive technology. + var accessibilityLabel: String { + var parts = [machine.displayName, machine.activityLabel, CloudMachineResourcePresentation(machine: machine).summary] + if machine.isDefault { + parts.append(String(localized: "machines.row.default.accessibilityLabel", defaultValue: "Default machine")) + } + return parts.joined(separator: ", ") + } + + /// Expands the row with its sample time, machine details, and optional billing usage. + var toolTip: String { + var lines = [machine.displayName, machine.activityLabel, CloudMachineResourcePresentation(machine: machine).summary] + if let stats = machine.stats { + lines.append(String( + format: String(localized: "cloudTree.resources.sampled", defaultValue: "Sampled %@"), + stats.sampledAt.formatted(date: .abbreviated, time: .standard) + )) + } + lines.append(subtitle) + lines.append(machine.image) + if let usageLine { lines.append(usageLine) } + return lines.joined(separator: "\n") + } + + /// "$1.23 · 41K tokens · 30d": coderouter spend over the usage window. Nil + /// when the machine routed nothing, so an idle machine shows no spend row. + var usageLine: String? { + guard let usage = machine.usage, !usage.totals.isEmpty else { return nil } + let cost = Self.usdFormatter.string(from: NSNumber(value: usage.totals.apiEquivalentUsd)) + ?? String(format: "$%.2f", usage.totals.apiEquivalentUsd) + let tokens = usage.totals.totalTokens.formatted(.number.notation(.compactName).precision(.fractionLength(0...1))) + let period = String( + format: String(localized: "machines.usage.period.days", defaultValue: "%dd"), + usage.periodDays + ) + return String( + format: String(localized: "machines.usage.line", defaultValue: "%1$@ \u{00B7} %2$@ tokens \u{00B7} %3$@"), + cost, tokens, period + ) + } + + /// API-equivalent spend is always in US dollars, whatever the user's locale. + private static let usdFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + formatter.currencySymbol = "$" + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + return formatter + }() + + /// The two-line layout's second line. Deliberately excludes the free-access + /// countdown: expiry is plan chrome (the panel header owns it), not a fact + /// about the machine. "Locked" stays — it explains a dead machine row. + var subtitle: String { + var parts: [String] = [] + if machine.showsName { + // Named machines keep their address visible: the id is what CLI + // verbs and URLs use. + parts.append(machine.id) + } + parts.append(machine.kindLabel) + if let createdAt = machine.createdAt { + parts.append(Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date())) + } + if machine.freeAccess == .expired { + parts.append(String(localized: "machines.row.locked", defaultValue: "Locked")) + } + return parts.joined(separator: " · ") + } + + /// Locked explains access behavior; resource and billing details have their own homes. + var inlineFact: String? { + machine.freeAccess == .expired + ? String(localized: "machines.row.locked", defaultValue: "Locked") + : nil + } + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() +} diff --git a/Sources/Cloud/CloudTreeNSOutlineView.swift b/Sources/Cloud/CloudTreeNSOutlineView.swift index b0ca0485c795..d96fdb635ca6 100644 --- a/Sources/Cloud/CloudTreeNSOutlineView.swift +++ b/Sources/Cloud/CloudTreeNSOutlineView.swift @@ -164,14 +164,14 @@ final class CloudTreeNSOutlineView: NSOutlineView { override func frameOfOutlineCell(atRow row: Int) -> NSRect { var frame = super.frameOfOutlineCell(atRow: row) frame.origin.x += Self.leadingMargin - if treeStyle.machineRowLayout == .twoLine, - let node = item(atRow: row) as? CloudTreeNode, node.isMachineRow { + if let node = item(atRow: row) as? CloudTreeNode, node.isMachineRow, + treeStyle.machineRowLayout == .twoLine || node.structureTag == "machine" { // Multi-line machine rows: the chevron centers on the name line (first // line, after the row's top padding), not on the row's vertical middle, // so it reads with the name and the status dot. NSTableView is flipped. let rowFrame = rect(ofRow: row) let nameLineCenter = rowFrame.minY - + GlobalFontMagnification.scaledSize(treeStyle.machineVerticalPadding) + + GlobalFontMagnification.scaledSize(treeStyle.machineVerticalPadding + (treeStyle.machineBand ? 4 : 0)) + GlobalFontMagnification.scaledSize(treeStyle.machineNameLineHeight) / 2 frame.origin.y = (nameLineCenter - frame.height / 2).rounded() } diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 4dd6c8f9d6a1..60832dabf936 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -354,11 +354,8 @@ struct CloudTreeOutlineView: NSViewRepresentable { func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { guard let node = item as? CloudTreeNode else { return GlobalFontMagnification.scaledSize(style.rowHeight) } switch node.kind { - case .machine(let machine, _): - let hasStats = machine.stats.flatMap(CloudTreeMachineRowContent.statsLine) != nil - // Same rule as usageLine (nil for empty totals), without formatting text per row. - let hasUsage = machine.usage.map { !$0.totals.isEmpty } ?? false - return GlobalFontMagnification.scaledSize(style.machineRowHeight(hasStats: hasStats, hasUsage: hasUsage)) + case .machine: + return GlobalFontMagnification.scaledSize(style.machineRowHeight(hasStats: true)) case .localMachine, .pendingMachine: return GlobalFontMagnification.scaledSize(style.machineRowHeight(hasStats: false)) case .terminalsPool, .displaysPool, .workspacesGroup, .portsGroup, .browsersGroup, .workspace, .localWorkspace, .terminal, .display, .browser, .port, .placeholder: diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index f2d66fb80e6b..91b5752ad0bf 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -14,7 +14,6 @@ enum CloudTreeRowGrid { static let trailingGap: CGFloat = 10 static let trailingSlot: CGFloat = 16 static let trailingPadding: CGFloat = 8 - static let machineStatsLineHeight: CGFloat = 13 static let machineLineSpacing: CGFloat = 1 } @@ -597,233 +596,6 @@ struct CloudTreeMachineBand: View { } } -/// The machine row's display content: activity dot, name, and — in the two-line -/// layout — subtitle plus optional stats. Hover buttons and menus live in the -/// outline cell, and click handling in the outline. -struct CloudTreeMachineRowContent: View { - let machine: MachineSnapshot - var style: CloudTreeStyle = CloudTreeStyleStore.current - - var body: some View { - switch style.machineRowLayout { - case .singleLine: - // Finder-like: dot, name, one dim inline fact. Everything else is - // in the tooltip and the context menu. - CloudTreeMachineBand(style: style) { - // No status dot (lawrence, 2026-08-27): the name starts right after - // the chevron. A locked (free-window-expired) machine keeps a lock - // glyph — that one changes what a click does. - HStack(alignment: .center, spacing: CloudTreeRowGrid.dotGap) { - if machine.freeAccess == .expired { - Image(systemName: "lock.fill") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(width: CloudTreeRowGrid.dotSlot, alignment: .center) - } else { - // This row is another computer: the same outline cloud as the - // titlebar Cloud button, dimmed so it doesn't compete with the name. - Image(systemName: "cloud") - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.secondary) - .frame(width: CloudTreeRowGrid.dotSlot, alignment: .center) - } - // Name and fact differ in point size, so they share a - // baseline (like the group and session rows above); the - // glyph stays centered against the row in the outer stack. - HStack(alignment: .firstTextBaseline, spacing: CloudTreeRowGrid.dotGap) { - Text(machine.displayName) - .cmuxFont(size: style.machineNameSize, weight: style.machineBand ? .semibold : .medium, design: style.fontDesign) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - if machine.isDefault { - Image(systemName: "star.fill") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(.secondary) - .help(String(localized: "machines.row.default.help", defaultValue: "Default machine for New Cloud Workspace")) - .accessibilityLabel(String(localized: "machines.row.default.accessibilityLabel", defaultValue: "Default machine")) - } - if let fact = Self.inlineFact(machine, style: style) { - Text(fact) - .cmuxFont(size: style.detailSize, design: style.fontDesign) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.tail) - } - } - Spacer(minLength: CloudTreeRowGrid.trailingGap) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel("\(machine.displayName), \(machine.activityLabel)") - case .twoLine: - // Top-aligned: the chevron sits on the name line (see - // `CloudTreeNSOutlineView.frameOfOutlineCell`), not on the row's middle. - // No status dot; only the expired lock earns the leading slot. - HStack(alignment: .top, spacing: CloudTreeRowGrid.dotGap) { - if machine.freeAccess == .expired { - Image(systemName: "lock.fill") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(width: CloudTreeRowGrid.dotSlot, height: style.machineNameLineHeight, alignment: .center) - } else { - Image(systemName: "cloud") - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.secondary) - .frame(width: CloudTreeRowGrid.dotSlot, height: style.machineNameLineHeight, alignment: .center) - } - VStack(alignment: .leading, spacing: CloudTreeRowGrid.machineLineSpacing) { - HStack(alignment: .firstTextBaseline, spacing: CloudTreeRowGrid.dotGap) { - Text(machine.displayName) - .cmuxFont(size: style.machineNameSize, weight: .medium, design: style.fontDesign) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - if machine.isDefault { - Image(systemName: "star.fill") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(.secondary) - .help(String(localized: "machines.row.default.help", defaultValue: "Default machine for New Cloud Workspace")) - .accessibilityLabel(String(localized: "machines.row.default.accessibilityLabel", defaultValue: "Default machine")) - } - } - .frame(height: style.machineNameLineHeight, alignment: .leading) - Text(Self.subtitle(machine)) - .cmuxFont(size: style.detailSize + 0.5, design: style.fontDesign) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: style.machineSubtitleLineHeight) - if style.showsMachineStats, let stats = machine.stats, let line = Self.statsLine(stats) { - // One dim line instead of colored gauges: the numbers carry the - // information; color would only compete with the status dot. - Text(line) - .cmuxFont(size: style.detailSize, design: style.fontDesign, monospacedDigit: true) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: CloudTreeRowGrid.machineStatsLineHeight) - } - if let usage = machine.usage, let line = Self.usageLine(usage) { - // Coderouter spend over the window, same dim treatment as stats. - Text(line) - .cmuxFont(size: style.detailSize, design: style.fontDesign, monospacedDigit: true) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: CloudTreeRowGrid.machineStatsLineHeight) - } - } - Spacer(minLength: CloudTreeRowGrid.trailingGap) - } - .padding(.vertical, style.machineVerticalPadding) - .padding(.trailing, CloudTreeRowGrid.trailingPadding) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(machine.displayName), \(machine.activityLabel)") - } - } - - /// "CPU 9% · Mem 3.4/3.8 GB · Disk 2.8/3.1 GB" for an awake machine, the - /// asleep line otherwise; nil when there is nothing to say yet. - static func statsLine(_ stats: VMStats) -> String? { - switch stats.state { - case .awake: - var parts: [String] = [] - if let cpu = stats.cpuPercent { - parts.append(String(format: String(localized: "cloudTree.stats.cpu", defaultValue: "CPU %d%%"), Int(cpu.rounded()))) - } - if let used = stats.memoryUsedMb, let total = stats.memoryTotalMb, total > 0 { - parts.append(String(format: String(localized: "cloudTree.stats.memory", defaultValue: "Mem %@/%@ GB"), gb(used), gb(total))) - } - if let used = stats.diskUsedMb, let total = stats.diskTotalMb, total > 0 { - parts.append(String(format: String(localized: "cloudTree.stats.disk", defaultValue: "Disk %@/%@ GB"), gb(used), gb(total))) - } - return parts.isEmpty ? nil : parts.joined(separator: " · ") - case .asleep: - return String(localized: "machines.stats.asleep", defaultValue: "Asleep \u{00B7} free while it sleeps") - case .unknown: - return nil - } - } - - private static func gb(_ mb: Int) -> String { - let value = Double(mb) / 1024 - return value >= 10 ? String(format: "%.0f", value) : String(format: "%.1f", value) - } - - /// "$1.23 · 41K tokens · 30d": coderouter spend over the usage window. Nil - /// when the machine routed nothing, so an idle machine shows no spend row. - static func usageLine(_ usage: MachineUsageSnapshot) -> String? { - guard !usage.totals.isEmpty else { return nil } - let cost = usdFormatter.string(from: NSNumber(value: usage.totals.apiEquivalentUsd)) - ?? String(format: "$%.2f", usage.totals.apiEquivalentUsd) - let tokens = usage.totals.totalTokens.formatted(.number.notation(.compactName).precision(.fractionLength(0...1))) - let period = String( - format: String(localized: "machines.usage.period.days", defaultValue: "%dd"), - usage.periodDays - ) - return String( - format: String(localized: "machines.usage.line", defaultValue: "%1$@ \u{00B7} %2$@ tokens \u{00B7} %3$@"), - cost, tokens, period - ) - } - - /// API-equivalent spend is always in US dollars, whatever the user's locale. - private static let usdFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "USD" - formatter.currencySymbol = "$" - formatter.minimumFractionDigits = 2 - formatter.maximumFractionDigits = 2 - return formatter - }() - - /// The two-line layout's second line. Deliberately excludes the free-access - /// countdown: expiry is plan chrome (the panel header owns it), not a fact - /// about the machine. "Locked" stays — it explains a dead machine row. - static func subtitle(_ machine: MachineSnapshot) -> String { - var parts: [String] = [] - if machine.showsName { - // Named machines keep their address visible: the id is what CLI - // verbs and URLs use. - parts.append(machine.id) - } - parts.append(machine.kindLabel) - if let createdAt = machine.createdAt { - parts.append(Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date())) - } - if machine.freeAccess == .expired { - parts.append(String(localized: "machines.row.locked", defaultValue: "Locked")) - } - return parts.joined(separator: " · ") - } - - /// The single-line layout's one dim fact: "Locked" when expired, else nothing. - static func inlineFact(_ machine: MachineSnapshot, style: CloudTreeStyle) -> String? { - if machine.freeAccess == .expired { - return String(localized: "machines.row.locked", defaultValue: "Locked") - } - // Single-line rows carry the live reading inline: the same CPU/Mem/Disk - // line the two-line card shows, dimmed after the name, then the - // coderouter spend when the backend reports any. - var parts: [String] = [] - if style.showsMachineStats, let stats = machine.stats, let line = statsLine(stats) { - parts.append(line) - } - if let usage = machine.usage, let line = usageLine(usage) { - parts.append(line) - } - return parts.isEmpty ? nil : parts.joined(separator: " · ") - } - - static let relativeFormatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter - }() -} - struct CloudTreeRowHoverButtons: View { let kind: CloudTreeNode.Kind let machineActions: MachineRowActions diff --git a/Sources/Cloud/CloudTreeStyle.swift b/Sources/Cloud/CloudTreeStyle.swift index fc085fd1bca5..fc7a3a8d19e2 100644 --- a/Sources/Cloud/CloudTreeStyle.swift +++ b/Sources/Cloud/CloudTreeStyle.swift @@ -9,9 +9,9 @@ import SwiftUI /// side by side so a variant is picked by looking, not by rebuilding. struct CloudTreeStyle: Equatable, Identifiable, Sendable { enum MachineRowLayout: String, Sendable { - /// Name line plus a dim subtitle (and stats when enabled). + /// Name and resources plus a dim metadata subtitle. case twoLine - /// One Finder-like line: dot, name, dim inline detail. + /// Compact name and resources, without a metadata subtitle. case singleLine } @@ -75,8 +75,7 @@ struct CloudTreeStyle: Equatable, Identifiable, Sendable { let showsGroupCounts: Bool /// The daemon-tab count badge on pool terminal rows. let showsViewBadges: Bool - /// The CPU/Mem/Disk reading: a line under the machine in two-line layout, - /// the dim inline fact after the name in single-line layout. + /// A dedicated CPU/RAM/disk strip below the cloud machine name in every layout. let showsMachineStats: Bool let machineVerticalPadding: CGFloat @@ -84,17 +83,21 @@ struct CloudTreeStyle: Equatable, Identifiable, Sendable { var machineNameLineHeight: CGFloat { machineNameSize + 3.5 } var machineSubtitleLineHeight: CGFloat { detailSize + 3.5 } - /// `hasUsage` adds the coderouter spend line under the stats in the - /// two-line layout; single-line rows carry it inline at a fixed height. - func machineRowHeight(hasStats: Bool, hasUsage: Bool = false) -> CGFloat { + var machineResourceHeight: CGFloat { detailSize + 2 + machineNameLineHeight + 1 } + + /// Resource columns reserve space even before the first sample arrives. + /// Local/pending rows pass false and keep the preset's original density. + func machineRowHeight(hasStats: Bool) -> CGFloat { + if hasStats && showsMachineStats { + return machineVerticalPadding * 2 + machineNameLineHeight + 4 + machineResourceHeight + + (machineRowLayout == .twoLine ? 1 + machineSubtitleLineHeight : 0) + + (machineBand ? 8 : 0) + } switch machineRowLayout { case .singleLine: return rowHeight + (machineBand ? 7 : 2) case .twoLine: - let lines = machineNameLineHeight + CloudTreeRowGrid.machineLineSpacing + machineSubtitleLineHeight - + (hasStats && showsMachineStats ? CloudTreeRowGrid.machineLineSpacing + CloudTreeRowGrid.machineStatsLineHeight : 0) - + (hasUsage ? CloudTreeRowGrid.machineLineSpacing + CloudTreeRowGrid.machineStatsLineHeight : 0) - return machineVerticalPadding * 2 + lines + return machineVerticalPadding * 2 + machineNameLineHeight + 1 + machineSubtitleLineHeight } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d62b487a662f..99bb42160d10 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -722,6 +722,7 @@ 80E8A5087134FBEF755993B6 /* CloudMachineNotificationEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3D5091A5F87B6B672DF34C /* CloudMachineNotificationEvent.swift */; }; 32297D75B3ED6ABE50D4C244 /* CloudMachineNotificationGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDC6087D41DC9E2271BC886 /* CloudMachineNotificationGate.swift */; }; C12306000000000000000002 /* CloudMachineRefreshCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12306000000000000000001 /* CloudMachineRefreshCoordinator.swift */; }; + 056D049E52C84DE4BAEA363C /* CloudMachineResourcePresentation+MachineSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7134CF3EB1C4101B13E787E /* CloudMachineResourcePresentation+MachineSnapshot.swift */; }; 0F5C2DDC1A39436E867D28BB /* CloudMachinesFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9143D900F124494A55F859F /* CloudMachinesFeature.swift */; }; A1B2C3D4E5F6071827364951 /* CloudMachineSurfacePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6071827364950 /* CloudMachineSurfacePresentation.swift */; }; FA0000000000000000000001 /* CloudManualInputOptionBackspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000002 /* CloudManualInputOptionBackspaceTests.swift */; }; @@ -781,6 +782,9 @@ 3BCFA4F10BC6F6673CFA3BFC /* CloudTreeCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E286761D314959F64AA002 /* CloudTreeCellView.swift */; }; 46C9B49F24011903C31179A7 /* CloudTreeExpansionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93142FA1472ACA28B993606 /* CloudTreeExpansionStore.swift */; }; B5C68F2FAFD21A103AFF429D /* CloudTreeMachineMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADC9BE77DF3A402B8EC51665 /* CloudTreeMachineMenuTests.swift */; }; + 67C1325431BC42F58AC69F1C /* CloudTreeMachineResourcesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4C8CD1419E452C8ACB72E5 /* CloudTreeMachineResourcesTests.swift */; }; + BE46EFA44C6E4699B64FE62E /* CloudTreeMachineResourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF43F26D03DC4827B4AC3E48 /* CloudTreeMachineResourceView.swift */; }; + 239CDE3EB72D4EFBBBD92783 /* CloudTreeMachineRowContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB09B103918144A9A420D765 /* CloudTreeMachineRowContent.swift */; }; C986A0030000000000000001 /* CloudTreeNativeDragOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986A0040000000000000001 /* CloudTreeNativeDragOwnershipTests.swift */; }; 494BF5E2F1B8BF0369D2DEBC /* CloudTreeNode+ErrorCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FD541FCCB10442050052A1C /* CloudTreeNode+ErrorCopy.swift */; }; 2F6A1B1B2A892D4CCD6BF386 /* CloudTreeNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CE0A82A4E20E446A21A97BB /* CloudTreeNode.swift */; }; @@ -4396,6 +4400,7 @@ D9077ACF715B2CE4B695A444 /* CloudMachineNotificationEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineNotificationEventTests.swift; sourceTree = ""; }; 8EDC6087D41DC9E2271BC886 /* CloudMachineNotificationGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineNotificationGate.swift; sourceTree = ""; }; C12306000000000000000001 /* CloudMachineRefreshCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineRefreshCoordinator.swift; sourceTree = ""; }; + E7134CF3EB1C4101B13E787E /* CloudMachineResourcePresentation+MachineSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CloudMachineResourcePresentation+MachineSnapshot.swift"; sourceTree = ""; }; D9143D900F124494A55F859F /* CloudMachinesFeature.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudMachinesFeature.swift; sourceTree = ""; }; A1B2C3D4E5F6071827364950 /* CloudMachineSurfacePresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudMachineSurfacePresentation.swift; sourceTree = ""; }; FA0000000000000000000002 /* CloudManualInputOptionBackspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualInputOptionBackspaceTests.swift; sourceTree = ""; }; @@ -4455,6 +4460,9 @@ 47E286761D314959F64AA002 /* CloudTreeCellView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeCellView.swift; sourceTree = ""; }; B93142FA1472ACA28B993606 /* CloudTreeExpansionStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeExpansionStore.swift; sourceTree = ""; }; ADC9BE77DF3A402B8EC51665 /* CloudTreeMachineMenuTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineMenuTests.swift; sourceTree = ""; }; + 4E4C8CD1419E452C8ACB72E5 /* CloudTreeMachineResourcesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineResourcesTests.swift; sourceTree = ""; }; + CF43F26D03DC4827B4AC3E48 /* CloudTreeMachineResourceView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineResourceView.swift; sourceTree = ""; }; + FB09B103918144A9A420D765 /* CloudTreeMachineRowContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineRowContent.swift; sourceTree = ""; }; C986A0040000000000000001 /* CloudTreeNativeDragOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeNativeDragOwnershipTests.swift; sourceTree = ""; }; 7FD541FCCB10442050052A1C /* CloudTreeNode+ErrorCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeNode+ErrorCopy.swift"; sourceTree = ""; }; 1CE0A82A4E20E446A21A97BB /* CloudTreeNode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeNode.swift; sourceTree = ""; }; @@ -7735,6 +7743,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A4C74749FA907F50D653EF65 /* CloudTreeNSOutlineView.swift */, 47E286761D314959F64AA002 /* CloudTreeCellView.swift */, 53075CD5172053B8F994DA2B /* CloudTreeRowContentView.swift */, + FB09B103918144A9A420D765 /* CloudTreeMachineRowContent.swift */, + CF43F26D03DC4827B4AC3E48 /* CloudTreeMachineResourceView.swift */, + E7134CF3EB1C4101B13E787E /* CloudMachineResourcePresentation+MachineSnapshot.swift */, B93142FA1472ACA28B993606 /* CloudTreeExpansionStore.swift */, 0F73B922CC243AB9A365C1B5 /* CloudTreeNodeActions.swift */, D12406000000000000000001 /* CloudTreeResizeMenu.swift */, @@ -10420,6 +10431,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */, 2217672A4FBF3048C6C17EA3 /* RightSidebarTabCustomizationTests.swift */, 78F49DA9F80DC43173341410 /* MachinesPanelModelTests.swift */, + 4E4C8CD1419E452C8ACB72E5 /* CloudTreeMachineResourcesTests.swift */, 763D217526D3832668F47568 /* CloudAgentSkillLauncherTests.swift */, 5130177E80C2A18E09DBBBC4 /* NewMachineModelTests.swift */, E56499DF099E405122930472 /* MachineCreateCoordinatorTests.swift */, @@ -11933,6 +11945,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 80E8A5087134FBEF755993B6 /* CloudMachineNotificationEvent.swift in Sources */, 32297D75B3ED6ABE50D4C244 /* CloudMachineNotificationGate.swift in Sources */, C12306000000000000000002 /* CloudMachineRefreshCoordinator.swift in Sources */, + 056D049E52C84DE4BAEA363C /* CloudMachineResourcePresentation+MachineSnapshot.swift in Sources */, 0F5C2DDC1A39436E867D28BB /* CloudMachinesFeature.swift in Sources */, A1B2C3D4E5F6071827364951 /* CloudMachineSurfacePresentation.swift in Sources */, C11324810000000000000001 /* CloudManualMirrorMaterialization.swift in Sources */, @@ -11975,6 +11988,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C986A0080000000000000001 /* CloudTreeActiveDrag.swift in Sources */, 3BCFA4F10BC6F6673CFA3BFC /* CloudTreeCellView.swift in Sources */, 46C9B49F24011903C31179A7 /* CloudTreeExpansionStore.swift in Sources */, + BE46EFA44C6E4699B64FE62E /* CloudTreeMachineResourceView.swift in Sources */, + 239CDE3EB72D4EFBBBD92783 /* CloudTreeMachineRowContent.swift in Sources */, 494BF5E2F1B8BF0369D2DEBC /* CloudTreeNode+ErrorCopy.swift in Sources */, 2F6A1B1B2A892D4CCD6BF386 /* CloudTreeNode.swift in Sources */, 7A0CE1000000000000000726 /* CloudTreeNodeActions+Prompts.swift in Sources */, @@ -14290,6 +14305,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 83EFE46711801D1A82B94FD1 /* CloudTerminalAttachmentRecoveryTests.swift in Sources */, FA0000000000000000000003 /* CloudTerminalPaneClosureTests.swift in Sources */, B5C68F2FAFD21A103AFF429D /* CloudTreeMachineMenuTests.swift in Sources */, + 67C1325431BC42F58AC69F1C /* CloudTreeMachineResourcesTests.swift in Sources */, C986A0030000000000000001 /* CloudTreeNativeDragOwnershipTests.swift in Sources */, 45F29E8BB64E4EE4BF204AC7 /* CloudTreeOneMachineManyWorkspacesTests.swift in Sources */, A12367000000000000000002 /* CloudTreeWorkspaceTitleLayoutTests.swift in Sources */, diff --git a/cmuxTests/CloudTreeMachineMenuTests.swift b/cmuxTests/CloudTreeMachineMenuTests.swift index 5efa075c01e4..c9154e795154 100644 --- a/cmuxTests/CloudTreeMachineMenuTests.swift +++ b/cmuxTests/CloudTreeMachineMenuTests.swift @@ -31,14 +31,6 @@ struct CloudTreeMachineMenuTests { #expect(!workspaceGroup.kind.refreshesOnExpansion) } - @Test("CLI disk resize parser enforces grow-only allocation steps") - func cliDiskResizeParserValidatesFreestyleSteps() { - #expect(CMUXCLI.parseCloudVMDiskMb("64G") == 64 * 1024) - #expect(CMUXCLI.parseCloudVMDiskMb("128 GiB") == nil) - #expect(CMUXCLI.parseCloudVMDiskMb("66G") == nil) - #expect(CMUXCLI.parseCloudVMDiskMb("260G") == nil) - } - @Test("A machine's menu exposes grow-only resource resize and wires its targets") func machineMenuOffersSupportedVerbs() throws { let recorder = CloudTreeMenuVerbRecorder() @@ -82,9 +74,9 @@ struct CloudTreeMachineMenuTests { let diskRoot = try #require(resizeMenu.items.first { $0.title == Self.title("machines.menu.increaseDisk", "Increase Disk") }) let diskMenu = try #require(diskRoot.submenu) #expect(diskMenu.items.map(\.title) == [ - Self.title("machines.menu.resizeToGiB", "Increase to 64 GiB"), - Self.title("machines.menu.resizeToGiB", "Increase to 128 GiB"), - Self.title("machines.menu.resizeToGiB", "Increase to 256 GiB"), + Self.title("machines.menu.resizeToGiB", "Increase to %d GiB", 64), + Self.title("machines.menu.resizeToGiB", "Increase to %d GiB", 128), + Self.title("machines.menu.resizeToGiB", "Increase to %d GiB", 256), ]) #expect(resizeMenu.items.map(\.title) == [ Self.title("machines.menu.increaseDisk", "Increase Disk"), @@ -95,16 +87,25 @@ struct CloudTreeMachineMenuTests { // The verbs that stay are still wired, not merely titled. try Self.choose(Self.title("machines.menu.openShell", "Open Shell"), in: menu) #expect(recorder.newTerminals == [.cloud(Self.machineID)]) - try Self.choose(Self.title("machines.menu.resizeToGiB", "Increase to 64 GiB"), in: diskMenu) - #expect(recorder.resizes == [(Self.machineID, 64)]) + try Self.choose(Self.title("machines.menu.resizeToGiB", "Increase to %d GiB", 64), in: diskMenu) + #expect(recorder.resizes.count == 1) + let diskResize = try #require(recorder.resizes.first) + #expect(diskResize.0 == Self.machineID) + #expect(diskResize.1 == 64) let cpuRoot = try #require(resizeMenu.items.first { $0.title == Self.title("machines.menu.increaseCPU", "Increase CPU") }) let cpuMenu = try #require(cpuRoot.submenu) - try Self.choose(Self.title("machines.menu.resizeToVCPUs", "Increase to 8 vCPUs"), in: cpuMenu) - #expect(recorder.cpuResizes == [(Self.machineID, 8)]) + try Self.choose(Self.title("machines.menu.resizeToVCPUs", "Increase to %d vCPUs", 8), in: cpuMenu) + #expect(recorder.cpuResizes.count == 1) + let cpuResize = try #require(recorder.cpuResizes.first) + #expect(cpuResize.0 == Self.machineID) + #expect(cpuResize.1 == 8) let memoryRoot = try #require(resizeMenu.items.first { $0.title == Self.title("machines.menu.increaseMemory", "Increase Memory") }) let memoryMenu = try #require(memoryRoot.submenu) - try Self.choose(Self.title("machines.menu.resizeToGiB", "Increase to 16 GiB"), in: memoryMenu) - #expect(recorder.memoryResizes == [(Self.machineID, 16)]) + try Self.choose(Self.title("machines.menu.resizeToGiB", "Increase to %d GiB", 16), in: memoryMenu) + #expect(recorder.memoryResizes.count == 1) + let memoryResize = try #require(recorder.memoryResizes.first) + #expect(memoryResize.0 == Self.machineID) + #expect(memoryResize.1 == 16) try Self.choose(Self.title("machines.menu.checkpoint", "Checkpoint"), in: menu) #expect(recorder.commands.map { $0.id } == [Self.machineID]) #expect(recorder.commands.map { $0.verb } == [["vm", "snapshot"]]) @@ -114,8 +115,9 @@ struct CloudTreeMachineMenuTests { /// The same catalog lookup the outline uses for its items, so the /// expectation holds in every locale. - private static func title(_ key: StaticString, _ defaultValue: String.LocalizationValue) -> String { - String(localized: key, defaultValue: defaultValue) + private static func title(_ key: StaticString, _ defaultValue: String.LocalizationValue, _ arguments: CVarArg...) -> String { + let format = String(localized: key, defaultValue: defaultValue) + return arguments.isEmpty ? format : String(format: format, arguments: arguments) } /// Fires the item the way AppKit does when the person picks it. diff --git a/cmuxTests/CloudTreeMachineResourcesTests.swift b/cmuxTests/CloudTreeMachineResourcesTests.swift new file mode 100644 index 000000000000..27644077ab4f --- /dev/null +++ b/cmuxTests/CloudTreeMachineResourcesTests.swift @@ -0,0 +1,94 @@ +import CmuxCloudMachines +import Foundation +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Cloud machine resource presentation") +struct CloudTreeMachineResourcesTests { + private func machine( + state: VMStats.State = .awake, + cpu: Double? = 9.4, + memoryUsed: Int? = 2048, + memoryTotal: Int? = 4096, + diskUsed: Int? = 3072, + diskTotal: Int? = 4096 + ) -> MachineSnapshot { + var result = MachineSnapshotBuilder.snapshot(from: VMSummary( + id: "resource-test", provider: "freestyle", status: "running", + image: "cmux-devbox:test", createdAt: 0, base: nil + )) + result.capabilities.stats = true + result.stats = VMStats( + state: state, sampledAt: Date(timeIntervalSince1970: 1_780_000_000), + cpus: 4, cpuPercent: cpu, loadAverage1m: nil, + memoryTotalMb: memoryTotal, memoryUsedMb: memoryUsed, + diskTotalMb: diskTotal, diskUsedMb: diskUsed + ) + return result + } + + @Test func awakeReadingsUseUtilizationRatherThanProvisionedCapacity() { + let resources = CloudMachineResourcePresentation(machine: machine()) + #expect(resources.cpu.percent == 9.4) + #expect(resources.memory.percent == 50) + #expect(resources.disk.percent == 75) + #expect(resources.cpu.value == (0.094).formatted(.percent.precision(.fractionLength(0)))) + #expect(resources.memory.detail.contains("2/4")) + #expect(resources.disk.detail.contains("3/4")) + } + + @Test(arguments: [VMStats.State.asleep, .unknown]) + func inactiveSamplesNeverPresentOldValuesAsLive(state: VMStats.State) { + let resources = CloudMachineResourcePresentation(machine: machine(state: state)) + #expect(resources.cpu.percent == nil) + #expect(resources.memory.percent == nil) + #expect(resources.disk.percent == nil) + } + + @Test func missingAndUnsupportedStatsDoNotInventZeroUsage() { + var snapshot = machine() + snapshot.stats = nil + let missing = CloudMachineResourcePresentation(machine: snapshot) + #expect(missing.cpu.percent == nil) + #expect(missing.memory.percent == nil) + #expect(missing.disk.percent == nil) + snapshot = machine() + snapshot.capabilities.stats = false + let unsupported = CloudMachineResourcePresentation(machine: snapshot) + #expect(unsupported.cpu.percent == nil) + #expect(unsupported.memory.percent == nil) + #expect(unsupported.disk.percent == nil) + } + + @Test @MainActor func refreshedSnapshotsUpdateReadingsWithoutReplacingRows() throws { + var first = machine() + first.stats = nil + let original = CloudTreeNodeBuilder.nodes(machines: [first], snapshot: .empty, localWorkspaces: [], includeLocalMachine: false) + let refreshed = CloudTreeNodeBuilder.nodes(machines: [machine(cpu: 83)], snapshot: .empty, localWorkspaces: [], includeLocalMachine: false) + let row = try #require(original.first) + let replacement = try #require(refreshed.first) + #expect(row.id == replacement.id) + #expect(row.structureTag == replacement.structureTag) + #expect(CloudTreeNodeBuilder.contentSignature(original) != CloudTreeNodeBuilder.contentSignature(refreshed)) + row.adopt(from: replacement) + guard case .machine(let snapshot, _) = row.kind else { + Issue.record("The refresh must retain the machine row") + return + } + #expect(CloudMachineResourcePresentation(machine: snapshot).cpu.percent == 83) + #expect(CloudTreeMachineRowContent(machine: snapshot).accessibilityLabel.contains("83")) + #expect(CloudTreeMachineRowContent(machine: snapshot).toolTip.contains("83")) + } + + @Test @MainActor func everyPresetReservesResourceSpaceAndKeepsNameClear() { + for style in CloudTreeStyle.presets { + #expect(style.showsMachineStats) + #expect(style.machineRowHeight(hasStats: true) >= style.machineNameLineHeight + style.machineResourceHeight) + #expect(CloudTreeMachineRowContent(machine: machine(), style: style).inlineFact == nil) + } + } +} diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index c5c951557fcd..c2e60989c8f9 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -986,15 +986,15 @@ final class MachinesPanelModelTests: XCTestCase { id: "warm-owl", provider: "freestyle", image: "cmux-xfce-vnc:latest", isDesktop: true, activity: .ready, createdAt: nil, label: nil, freeAccess: .active(daysLeft: 3) ) - XCTAssertFalse(CloudTreeMachineRowContent.subtitle(active).contains("3"), "expiry is plan chrome, not a machine fact") - XCTAssertNil(CloudTreeMachineRowContent.inlineFact(active, style: .compact)) + XCTAssertFalse(CloudTreeMachineRowContent(machine: active).subtitle.contains("3"), "expiry is plan chrome, not a machine fact") + XCTAssertNil(CloudTreeMachineRowContent(machine: active, style: .compact).inlineFact) let expired = MachineSnapshot( id: "warm-owl", provider: "freestyle", image: "cmux-xfce-vnc:latest", isDesktop: true, activity: .attention("locked"), createdAt: nil, label: nil, freeAccess: .expired ) - XCTAssertTrue(CloudTreeMachineRowContent.subtitle(expired).contains("Locked"), "a dead row still explains itself") - XCTAssertNotNil(CloudTreeMachineRowContent.inlineFact(expired, style: .compact)) + XCTAssertTrue(CloudTreeMachineRowContent(machine: expired).subtitle.contains("Locked"), "a dead row still explains itself") + XCTAssertNotNil(CloudTreeMachineRowContent(machine: expired, style: .compact).inlineFact) } func testCloudTreeStylePresetsAreDistinctAndResolvable() { @@ -1011,9 +1011,9 @@ final class MachinesPanelModelTests: XCTestCase { XCTAssertNil(CloudTreeStyle.preset(id: "bogus")) // The presets are different shapes, not one look at five sizes. XCTAssertEqual(Set(presets.map { "\($0.leafLayout)|\($0.iconTreatment)|\($0.groupLabelStyle)|\($0.metaPlacement)|\($0.machineBand)|\($0.monospacedText)" }).count, presets.count, "every preset differs structurally") - // Two-line cards grow with the stats line; single-line rows never do. + // Every cloud style reserves a dedicated resource strip. XCTAssertGreaterThan(CloudTreeStyle.aero.machineRowHeight(hasStats: true), CloudTreeStyle.aero.machineRowHeight(hasStats: false)) - XCTAssertEqual(CloudTreeStyle.compact.machineRowHeight(hasStats: true), CloudTreeStyle.compact.machineRowHeight(hasStats: false)) + XCTAssertGreaterThan(CloudTreeStyle.compact.machineRowHeight(hasStats: true), CloudTreeStyle.compact.machineRowHeight(hasStats: false)) } func testDropDestinationMapsEverySplitSideAndInserts() { @@ -1152,8 +1152,7 @@ final class CloudTreeScopeAndSignatureTests: XCTestCase { } } -/// Pins the single-line machine row's inline fact: Locked wins, then the live -/// CPU/Mem/Disk reading (when the style shows stats), else nothing. +/// Resource readings have their own strip, leaving only Locked beside the name. @Suite("Cloud tree machine inline fact") struct CloudTreeMachineInlineFactTests { private func snapshot(stats: VMStats?) -> MachineSnapshot { @@ -1164,20 +1163,20 @@ struct CloudTreeMachineInlineFactTests { return machine } - @Test("An awake reading renders inline in the default single-line style") - func awakeReadingRendersInline() { + @Test("Resource readings do not compete with the machine name") + func awakeReadingHasDedicatedSpace() { let stats = VMStats( state: .awake, sampledAt: Date(timeIntervalSince1970: 0), cpus: 2, cpuPercent: 9.4, loadAverage1m: nil, memoryTotalMb: 3891, memoryUsedMb: 3481, diskTotalMb: 3174, diskUsedMb: 2867 ) - let fact = CloudTreeMachineRowContent.inlineFact(snapshot(stats: stats), style: .compact) - #expect(fact?.contains("CPU 9%") == true) - #expect(fact?.contains("3.4/3.8") == true) + let fact = CloudTreeMachineRowContent(machine: snapshot(stats: stats), style: .compact).inlineFact + #expect(fact == nil) + #expect(CloudTreeStyle.compact.machineRowHeight(hasStats: true) > CloudTreeStyle.compact.machineRowHeight(hasStats: false)) } @Test("No reading yet means no inline fact") func missingStatsShowsNothing() { - #expect(CloudTreeMachineRowContent.inlineFact(snapshot(stats: nil), style: .compact) == nil) + #expect(CloudTreeMachineRowContent(machine: snapshot(stats: nil), style: .compact).inlineFact == nil) } } @@ -1385,25 +1384,21 @@ struct MachineUsageReadoutTests { let usage = try MachineUsageClient.decodeTeamUsage(payload) let byID = usage.byMachineID let wren = try #require(byID["noble-wren"]) - let line = try #require(CloudTreeMachineRowContent.usageLine(wren)) + var withUsage = machine("noble-wren") + withUsage.usage = wren + let line = try #require(CloudTreeMachineRowContent(machine: withUsage).usageLine) #expect(line.hasPrefix("$1.23"), "two decimals, USD: \(line)") #expect(line.contains("41K"), "compact token count: \(line)") #expect(line.hasSuffix("30d"), "window label: \(line)") let owl = try #require(byID["idle-owl"]) - #expect(CloudTreeMachineRowContent.usageLine(owl) == nil) + var idle = machine("idle-owl") + idle.usage = owl + #expect(CloudTreeMachineRowContent(machine: idle).usageLine == nil) - var withUsage = machine("noble-wren") - withUsage.usage = byID["noble-wren"] - let fact = try #require(CloudTreeMachineRowContent.inlineFact(withUsage, style: .compact)) - #expect(fact.contains("$1.23"), "single-line rows carry the spend inline") - #expect(CloudTreeMachineRowContent.inlineFact(machine("noble-wren"), style: .compact) == nil) + let fact = CloudTreeMachineRowContent(machine: withUsage, style: .compact).inlineFact + #expect(fact == nil, "spend belongs in the tooltip, leaving row space for resources") + #expect(CloudTreeMachineRowContent(machine: withUsage).toolTip.contains(line), "spend stays available on hover") } - @Test("Two-line rows grow by one line for the spend readout") - func twoLineHeightGrows() { - let twoLine = CloudTreeStyle.presets.first { $0.machineRowLayout == .twoLine } - guard let twoLine else { return } - #expect(twoLine.machineRowHeight(hasStats: false, hasUsage: true) > twoLine.machineRowHeight(hasStats: false)) - #expect(CloudTreeStyle.compact.machineRowHeight(hasStats: false, hasUsage: true) == CloudTreeStyle.compact.machineRowHeight(hasStats: false)) - } + } diff --git a/cmuxTests/MachinesPanelUncappedPlanTests.swift b/cmuxTests/MachinesPanelUncappedPlanTests.swift index 27f82c264a0f..797cf37b4378 100644 --- a/cmuxTests/MachinesPanelUncappedPlanTests.swift +++ b/cmuxTests/MachinesPanelUncappedPlanTests.swift @@ -58,7 +58,7 @@ struct MachinesPanelUncappedPlanTests { let unlabeled = MachineSnapshotBuilder.snapshot(from: summary) #expect(unlabeled.label == nil) #expect(unlabeled.displayName == "sleepy-teal-otter") - #expect(CloudTreeMachineRowContent.subtitle(unlabeled).contains(summary.id)) + #expect(CloudTreeMachineRowContent(machine: unlabeled).subtitle.contains(summary.id)) summary.displayName = "dev box" #expect(MachineSnapshotBuilder.snapshot(from: summary).displayName == "dev box") diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index 03fbfd2824db..f39433626087 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -1440,7 +1440,18 @@ }, "machines.stats.cpu": { "class": "technical", - "source": "CPU" + "source": "CPU", + "identityLocales": { + "bs": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "da": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "it": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "km": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "nb": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "pl": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "pt-BR": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "th": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "tr": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required." + } }, "machines.stats.memory": { "source": "Mem", @@ -3030,5 +3041,24 @@ "da": "Standard technical UI term with the same spelling in this language.", "nb": "Standard technical UI term with the same spelling in this language." } + }, + "cloudTree.resources.missing": { + "source": "—", + "class": "format" + }, + "cloudTree.resources.ram": { + "source": "RAM", + "class": "technical", + "identityLocales": { + "bs": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "da": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "it": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "km": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "nb": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "pl": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "pt-BR": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "th": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required.", + "tr": "Established resource abbreviation with the same spelling; an explicit translated catalog entry remains required." + } } } diff --git a/skills/cmux-cloud-vm/references/commands.md b/skills/cmux-cloud-vm/references/commands.md index 808cdeece752..71cbf10b04c3 100644 --- a/skills/cmux-cloud-vm/references/commands.md +++ b/skills/cmux-cloud-vm/references/commands.md @@ -139,14 +139,19 @@ Socket `vm.stats`. CPU, memory, and disk right now; a sleeping machine reports ` cmux vm resize [--cpu <1|2|…|32>] [--memory <4|5|…|64>G] [--disk <4|8|…|256>G] [--json] ``` -Grows the machine's persistent disk; it never shrinks or recreates the VM. The -value is GiB in 4 GiB steps, from 4 GiB through 256 GiB. The client validates -that range before sending `pane.resize {id, storage_mb}`; the provider returns the -post-resize `VMStats` payload. Text is `OK disk= GiB`; `--json` returns -that stats object. A resize can take a provider minute and consumes plan -storage, so confirm the target machine and desired size before running it; use -`cmux vm stats ` afterward to verify the mounted capacity. Sidebar: machine -row › Resize Disk… (the same action invokes this verb). +Grows CPU, memory, and/or persistent disk on the existing machine. CPU accepts +1–32 vCPUs; memory accepts 4–64 GiB in whole-GiB steps; disk accepts 4–256 GiB +in 4 GiB steps. Supply at least one dimension. Omitted dimensions stay unchanged, +and every requested dimension must be at least its current size. Plan limits +can further restrict these ranges. + +Socket `vm.resize` accepts `{id, cpu?, memory_mb?, storage_mb?}` and returns the +provider-confirmed `VMStats` object, including `cpus`, `memory_total_mb`, and +`disk_total_mb`. Text is `OK cpu= memory= GiB disk= GiB`; `--json` +returns the stats object. A resize can take a provider minute and consumes plan +resources, so confirm the machine and desired sizes before running it, then use +`cmux vm stats ` to verify the result. Sidebar: machine row › Resize machine +› Increase CPU / Increase Memory / Increase Disk uses the same action path. ### `cmux vm wait` @@ -627,6 +632,7 @@ cmux rpc [json-params] # call any v2 method directly, e.g. cmux | `vm.base_open`, `vm.base_reset` | `vm base open`, `vm base reset` | | `vm.status` | `vm status`, `vm handoff`, `vm wait` | | `vm.stats` | `vm stats`; the router's load scoring | +| `vm.diagnostics` | `cmux rpc vm.diagnostics '{}'` returns the app's cloud-operation report; `{"show":true}` also opens the diagnostics window | | `vm.resize` | `vm resize [--cpu …] [--memory …] [--disk …]`; machine row › Resize machine | | `vm.rename` | `vm new --name` and the router's `agent-pool` label; direct machine-label editing is currently a sidebar action | | `vm.tab_rename` | `vm tab rename` | diff --git a/tests/test_cli_vm_resize.py b/tests/test_cli_vm_resize.py index 697218f14707..db102818e0e1 100644 --- a/tests/test_cli_vm_resize.py +++ b/tests/test_cli_vm_resize.py @@ -121,6 +121,13 @@ def test_resize_uses_existing_id_and_prints_confirmed_shape(self) -> None: self.assertIn("memory=6 GiB", result.stdout) self.assertIn("disk=68 GiB", result.stdout) + def test_disk_suffix_is_converted_before_sending_the_resize(self) -> None: + with ResizeSocket({"disk_total_mb": 65536}) as server: + result = self.run_cli(server.path, ["vm", "resize", "existing-vm", "--disk", "64G", "--json"]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(server.requests), 1) + self.assertEqual(server.requests[0]["params"], {"id": "existing-vm", "storage_mb": 65536}) + def test_json_preserves_provider_confirmation(self) -> None: confirmed = {"id": "existing-vm", "state": "running", "cpus": 8, "memory_total_mb": 8192, "disk_total_mb": 69632} with ResizeSocket(confirmed) as server: @@ -134,6 +141,8 @@ def test_invalid_arguments_do_not_send_a_resize_request(self) -> None: [], ["existing-vm"], ["", "--disk", "64"], ["existing-vm", "--disk"], ["existing-vm", "--disk", "66"], ["existing-vm", "--disk", "0"], ["existing-vm", "--disk", "260"], + ["existing-vm", "--disk", "128 GiB"], ["existing-vm", "--disk", "66G"], + ["existing-vm", "--disk", "260G"], ["existing-vm", "--disk", "64.5"], ["existing-vm", "--disk", "64", "extra"], ["existing-vm", "--cpu", "0"], ["existing-vm", "--cpu", "33"], ["existing-vm", "--cpu", "1.5"], ["existing-vm", "--cpu", "-1"], diff --git a/web/app/api/vm/resource-usage/self/route.ts b/web/app/api/vm/resource-usage/self/route.ts new file mode 100644 index 000000000000..a6bba0c3004e --- /dev/null +++ b/web/app/api/vm/resource-usage/self/route.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect"; +import { VmRepository } from "@/services/vms/repository"; +import { makeVmResourceUsageHandler } from "@/services/vms/resourceUsageIngest"; +import { requireVmPrincipal } from "@/services/vms/vmPrincipal"; +import { runVmWorkflow } from "@/services/vms/workflows"; + +export const POST = makeVmResourceUsageHandler({ + authenticate: requireVmPrincipal, + now: Date.now, + accept: (vm, usage, receivedAt) => runVmWorkflow(Effect.gen(function* () { + const repo = yield* VmRepository; + if (!repo.recordResourceUsage) return yield* Effect.dieMessage("VM resource repository is unavailable"); + yield* repo.recordResourceUsage({ + id: vm.id, + providerVmId: vm.providerVmId, + usage, + receivedAt, + }); + })), +}); diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index adc719486f00..5a4c0e783b2f 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -13,6 +13,7 @@ import { import { createHash, randomBytes } from "node:crypto"; import { Effect } from "effect"; import { announceFreestyleNetwork } from "./freestyleNetworkAnnouncement"; +import { guestResourceReporterInstallCommand } from "../guestResourceReporter"; import { ProviderError, type AttachTransport, @@ -964,7 +965,7 @@ export class FreestyleProvider implements VMProvider { // The in-VM shim is a separate convenience layer over the baked // daemon and is installed idempotently for agents and peer links. - await this.installGuestCli(vm); + await this.installGuestCli(vm, vmId); await this.announcePrivateAddresses(vm, data); } catch (err) { // A VM that failed to size or configure must not survive as an @@ -1153,7 +1154,7 @@ export class FreestyleProvider implements VMProvider { const vm = fs.vms.ref(vmId); const expected = createHash("sha256").update(GUEST_CMUX_SHIM).digest("hex"); const current = await this.execResult(vm, `test "$(sha256sum '${GUEST_CMUX_SHIM_PATH}' 2>/dev/null | cut -d ' ' -f 1)" = '${expected}'`); - if (current?.exitCode !== 0) await this.installGuestCli(vm); + if (current?.exitCode !== 0) await this.installGuestCli(vm, vmId); const r = await vm.exec({ command, timeoutMs, linuxUser: GUEST_LINUX_USER }); // statusCode is null when the guest killed the command at its timeout. const exitCode = r.statusCode ?? 124; @@ -1328,7 +1329,7 @@ export class FreestyleProvider implements VMProvider { // remains best-effort for a transient resume race. The new machine's // edge rule is supplied inline, so its route is still fail-closed. try { - await this.installGuestCli(vm); + await this.installGuestCli(vm, vmId); await this.ensureCmuxTuiRunning(vm, vmId, false).catch(() => undefined); await this.announcePrivateAddresses(vm, data); } catch (err) { @@ -1399,6 +1400,7 @@ export class FreestyleProvider implements VMProvider { // that predates hook installation gets its Claude Code and Codex // hooks (best effort inside). await this.ensureAgentHooks(vm, vmId); + await this.ensureResourceReporter(vm, vmId); } if (!bundleResult || bundleResult.exitCode !== 0) { throw new ProviderError( @@ -1580,7 +1582,7 @@ export class FreestyleProvider implements VMProvider { */ private async ensureCmuxTuiRunning(vm: Vm, vmId: string, installGuestCli = true): Promise { // Keep the shim present even when the baked daemon is already healthy. - if (installGuestCli) await this.installGuestCli(vm); + if (installGuestCli) await this.installGuestCli(vm, vmId); const healthy = await this.execResult(vm, freestyleDaemonSettledCommand(), DAEMON_SETTLE_TIMEOUT_MS + EXEC_OVERHEAD_TIMEOUT_MS); if (healthy?.exitCode === 0) { await this.ensureAgentHooks(vm, vmId); @@ -1635,6 +1637,15 @@ export class FreestyleProvider implements VMProvider { await this.execOrThrow(vm, vmId, cmuxTuiAgentHooksInstallCommand(source), CMUX_TUI_INSTALL_TIMEOUT_MS); } + /** Advisory telemetry must not prevent the machine or its CLI from working. */ + private async ensureResourceReporter(vm: Vm, vmId: string): Promise { + try { + await this.execOrThrow(vm, vmId, guestResourceReporterInstallCommand(), 5_000); + } catch (error) { + console.warn(`[freestyle] ${vmId}: resource reporter not installed: ${errorMessage(error)}`); + } + } + /** * Installs the in-VM `cmux` shim with an upload-then-rename. The temporary * path avoids following a pre-existing `/usr/local/bin/cmux` symlink and the @@ -1642,7 +1653,7 @@ export class FreestyleProvider implements VMProvider { * the adapter on older images; create/attach callers treat a failed install * as a failed heal. */ - private async installGuestCli(vm: Vm): Promise { + private async installGuestCli(vm: Vm, vmId: string): Promise { const temporaryPath = `${GUEST_CMUX_SHIM_PATH}.tmp-${randomBytes(12).toString("hex")}`; try { await vm.fs.writeTextFile(temporaryPath, GUEST_CMUX_SHIM, { mode: 0o755 }); @@ -1659,6 +1670,7 @@ export class FreestyleProvider implements VMProvider { await vm.fs.remove(temporaryPath).catch(() => undefined); throw error; } + await this.ensureResourceReporter(vm, vmId); } private async execResult(vm: Vm, command: string, timeoutMs = EXEC_DEFAULT_TIMEOUT_MS): Promise { diff --git a/web/services/vms/errors.ts b/web/services/vms/errors.ts index 45a95b22f516..cce9f9608fd8 100644 --- a/web/services/vms/errors.ts +++ b/web/services/vms/errors.ts @@ -47,7 +47,7 @@ export class VmResizePlanLimitError extends Data.TaggedError("VmResizePlanLimitE readonly upgradePlanId?: string; }> {} -/** A grow-only disk resize is already running for this machine. */ +/** Another resize owns or has superseded this machine's resource reservation. */ export class VmResizeInProgressError extends Data.TaggedError("VmResizeInProgressError")<{ readonly vmId: string; }> {} diff --git a/web/services/vms/guestResourceReporter.ts b/web/services/vms/guestResourceReporter.ts new file mode 100644 index 000000000000..503459dbc843 --- /dev/null +++ b/web/services/vms/guestResourceReporter.ts @@ -0,0 +1,92 @@ +import { shellQuote } from "./drivers/cmuxTuiDaemon"; +import { vmEdgeAliasDomain, VM_PLACEHOLDER_API_KEY } from "../coderouter/vmGuestEnv"; + +/** CPU uses a 250 ms counter delta; memory excludes reclaimable cache. Units are MiB. */ +export const GUEST_RESOURCE_SAMPLE_SCRIPT = `import json, os, time + +def sample(): + + def cpu(): + with open('/proc/stat') as stream: + values = [int(value) for value in stream.readline().split()[1:9]] + return sum(values), values[3] + values[4] + + stats = {} + try: + first_total, first_idle = cpu() + time.sleep(0.25) + total, idle = cpu() + elapsed = total - first_total + if elapsed > 0: + stats['cpuPercent'] = max(0, min(100, 100 * (1 - (idle - first_idle) / elapsed))) + except (OSError, ValueError, IndexError): + pass + try: + with open('/proc/meminfo') as stream: + memory = {parts[0].rstrip(':'): int(parts[1]) for parts in (line.split() for line in stream)} + stats['memoryUsedMb'] = max(0, memory['MemTotal'] - memory['MemAvailable']) // 1024 + except (OSError, ValueError, KeyError, IndexError): + pass + try: + disk = os.statvfs('/') + stats['diskUsedMb'] = (disk.f_blocks - disk.f_bfree) * disk.f_frsize // (1024 * 1024) + except OSError: + pass + return stats +`; + +/** The awake guest publishes through its existing edge identity; no secret is stored here. */ +export function guestResourceReporterScript(): string { + return `${GUEST_RESOURCE_SAMPLE_SCRIPT} +import ssl, urllib.request +context = ssl.create_default_context() +ca = '/usr/local/share/ca-certificates/freestyle-tls.crt' +if os.path.isfile(ca): + context.load_verify_locations(ca) +while True: + try: + request = urllib.request.Request( + ${JSON.stringify(`https://${vmEdgeAliasDomain()}/api/vm/resource-usage/self`)}, + data=json.dumps(sample()).encode(), method='POST', + headers={'Content-Type': 'application/json', 'Authorization': ${JSON.stringify(`Bearer ${VM_PLACEHOLDER_API_KEY}`)}}) + with urllib.request.urlopen(request, timeout=5, context=context) as response: + response.read(1024) + except Exception: + pass + time.sleep(30) +`; +} + +/** Installed only on explicit create/attach/wake paths, never by a stats read. */ +export function guestResourceReporterInstallCommand(): string { + const script = guestResourceReporterScript(); + const unit = `[Unit] +Description=cmux resource statistics +After=network-online.target +[Service] +Type=simple +User=nobody +ExecStart=/usr/bin/python3 /usr/local/lib/cmux/resource-stats.py +Restart=always +RestartSec=30 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +[Install] +WantedBy=multi-user.target +`; + return `set -eu +install -d -m 0755 /usr/local/lib/cmux +cmux_stats_tmp=$(mktemp -d) +trap 'rm -rf "$cmux_stats_tmp"' EXIT +printf %s ${shellQuote(script)} > "$cmux_stats_tmp/script" +printf %s ${shellQuote(unit)} > "$cmux_stats_tmp/unit" +if ! cmp -s "$cmux_stats_tmp/script" /usr/local/lib/cmux/resource-stats.py || ! cmp -s "$cmux_stats_tmp/unit" /etc/systemd/system/cmux-resource-stats.service; then + install -m 0644 "$cmux_stats_tmp/script" /usr/local/lib/cmux/resource-stats.py + install -m 0644 "$cmux_stats_tmp/unit" /etc/systemd/system/cmux-resource-stats.service + systemctl daemon-reload + systemctl restart cmux-resource-stats.service +fi +systemctl enable --now cmux-resource-stats.service >/dev/null 2>&1`; +} diff --git a/web/services/vms/repository.ts b/web/services/vms/repository.ts index c33f54827e2e..f74171cab706 100644 --- a/web/services/vms/repository.ts +++ b/web/services/vms/repository.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm"; +import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -27,6 +27,7 @@ import { } from "../account/deletionLock"; import type { ProviderId } from "./drivers"; import { allocateVmSlug } from "./vmNaming"; +import { VM_RESOURCE_USAGE_KEY, VM_RESOURCE_USAGE_MIN_INTERVAL_MS, type VmResourceUsage } from "./resourceUsage"; import { VmCreateDisabledError, VmCreateInProgressError, @@ -255,6 +256,13 @@ export type VmRepositoryShape = { readonly id: string; readonly patch: Readonly>; }) => Effect.Effect; + /** Atomically coalesce advisory samples; false also covers a replaced VM. */ + readonly recordResourceUsage?: (input: { + readonly id: string; + readonly providerVmId: string; + readonly usage: VmResourceUsage; + readonly receivedAt: number; + }) => Effect.Effect; readonly claimBillingGrant: (input: { readonly billingCustomerType: string; readonly billingCustomerId: string; @@ -1296,6 +1304,27 @@ export const vmRepositoryLiveShape: VmRepositoryShape = { .where(eq(cloudVms.id, input.id)); }), + recordResourceUsage: (input) => + dbEffect("recordResourceUsage", async () => { + const sample = sql`${cloudVms.providerMetadata} -> ${VM_RESOURCE_USAGE_KEY}::text`; + const previousTime = sql`case when jsonb_typeof(${sample} -> 'receivedAt') = 'number' + then (${sample} ->> 'receivedAt')::numeric else null end`; + const patch = { [VM_RESOURCE_USAGE_KEY]: { ...input.usage, receivedAt: input.receivedAt, providerVmId: input.providerVmId } }; + const rows = await cloudDb().update(cloudVms).set({ + // Samples have their own timestamp; they are not lifecycle mutations. + providerMetadata: sql`${cloudVms.providerMetadata} || ${JSON.stringify(patch)}::jsonb`, + }).where(and( + eq(cloudVms.id, input.id), + eq(cloudVms.providerVmId, input.providerVmId), + or( + sql`(${sample} ->> 'providerVmId') is distinct from ${input.providerVmId}`, + isNull(previousTime), + lte(previousTime, input.receivedAt - VM_RESOURCE_USAGE_MIN_INTERVAL_MS), + ), + )).returning({ id: cloudVms.id }); + return rows.length > 0; + }), + listUserVms: (userId, billingTeamId) => dbEffect("listUserVms", async () => { const db = cloudDb(); diff --git a/web/services/vms/resourceUsage.ts b/web/services/vms/resourceUsage.ts new file mode 100644 index 000000000000..b4219a1c19f3 --- /dev/null +++ b/web/services/vms/resourceUsage.ts @@ -0,0 +1,44 @@ +import type { VMStats } from "./drivers/types"; + +export const VM_RESOURCE_USAGE_KEY = "cmuxResourceUsage"; +export const VM_RESOURCE_USAGE_MAX_AGE_MS = 90_000; +export const VM_RESOURCE_USAGE_MIN_INTERVAL_MS = 15_000; + +export type VmResourceUsage = Pick; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : null; +} + +/** Whitelist gauges only; a guest cannot claim identity, timestamps, or capacity. */ +export function parseVmResourceUsage(value: unknown): VmResourceUsage | null { + const input = record(value); + if (!input) return null; + const result: { cpuPercent?: number; memoryUsedMb?: number; diskUsedMb?: number } = {}; + for (const key of ["cpuPercent", "memoryUsedMb", "diskUsedMb"] as const) { + const number = input[key]; + if (number === undefined) continue; + if (typeof number !== "number" || !Number.isFinite(number) || number < 0) return null; + if (key === "cpuPercent" ? number > 100 : !Number.isSafeInteger(number)) return null; + result[key] = number; + } + return Object.keys(result).length ? result : null; +} + +/** The provider owns state and dimensions. Only a fresh sample from this guest supplies usage. */ +export function applyVmResourceUsage( + stats: VMStats, + metadata: Readonly>, + providerVmId: string, + now: number, +): VMStats { + if (stats.state !== "awake") return stats; + const sample = record(metadata[VM_RESOURCE_USAGE_KEY]); + if (!sample || sample.providerVmId !== providerVmId) return stats; + const receivedAt = sample.receivedAt; + if (typeof receivedAt !== "number" || !Number.isFinite(receivedAt) + || receivedAt > now || now - receivedAt > VM_RESOURCE_USAGE_MAX_AGE_MS) return stats; + const usage = parseVmResourceUsage(sample); + return usage ? { ...stats, ...usage, sampledAt: receivedAt } : stats; +} diff --git a/web/services/vms/resourceUsageIngest.ts b/web/services/vms/resourceUsageIngest.ts new file mode 100644 index 000000000000..b38b902b8868 --- /dev/null +++ b/web/services/vms/resourceUsageIngest.ts @@ -0,0 +1,38 @@ +import { Effect } from "effect"; +import { readBoundedJsonObject } from "../apns/routePolicy"; +import { parseVmResourceUsage, type VmResourceUsage } from "./resourceUsage"; +import { vmPrincipalFailureStatus, type VmPrincipalResult, type VmPrincipalRow } from "./vmPrincipalContract"; + +export type VmResourceUsageIngestDependencies = { + readonly authenticate: (request: Request) => Promise; + readonly accept: (vm: VmPrincipalRow & { readonly providerVmId: string }, usage: VmResourceUsage, receivedAt: number) => Promise; + readonly now: () => number; +}; + +/** A machine can update only its own advisory gauges, through its existing edge identity. */ +export function makeVmResourceUsageHandler(deps: VmResourceUsageIngestDependencies) { + return (request: Request): Promise => Effect.runPromise( + Effect.gen(function* () { + const auth = yield* Effect.tryPromise(() => deps.authenticate(request)); + if (!auth.ok) return response(vmPrincipalFailureStatus(auth.reason), auth.reason); + const body = yield* Effect.tryPromise(() => readBoundedJsonObject(request, 1024)); + if (!body.ok) return response(body.error === "request_too_large" ? 413 : 400, body.error); + const usage = parseVmResourceUsage(body.value); + if (!usage) return response(400, "invalid_resource_usage"); + const vm = auth.principal.vm; + const providerVmId = vm.providerVmId; + if (!providerVmId) return response(409, "vm_not_ready"); + const now = deps.now(); + // The repository decides eligibility atomically against the current row, + // rather than this potentially concurrent authentication snapshot. + yield* Effect.tryPromise(() => deps.accept({ ...vm, providerVmId }, usage, now)); + return response(204); + }).pipe(Effect.catchAll(() => Effect.succeed(response(503, "resource_usage_unavailable")))), + ); +} + +function response(status: number, error?: string): Response { + return new Response(error ? JSON.stringify({ error }) : null, { + status, headers: { "content-type": "application/json", "cache-control": "no-store" }, + }); +} diff --git a/web/services/vms/vmPrincipal.ts b/web/services/vms/vmPrincipal.ts index 2c24ca253c71..239d5eb61ce5 100644 --- a/web/services/vms/vmPrincipal.ts +++ b/web/services/vms/vmPrincipal.ts @@ -10,9 +10,10 @@ // row that the token's user or team owns. // // Deny by default. A VM principal is accepted ONLY by the guest-facing reflection -// routes (app/api/vm/reflection); no other `/api/vm/*` route reads it, and no user +// routes (app/api/vm/reflection) and its own resource-usage report; no user // (Stack) session is ever accepted on the guest-facing routes. The authority a -// machine gets is therefore read-only knowledge about itself and its siblings. +// machine gets is read-only knowledge about itself and its siblings, plus +// permission to publish only its own advisory resource gauges. import { and, eq } from "drizzle-orm"; import { cloudDb } from "../../db/client"; import { cloudVms } from "../../db/schema"; @@ -20,7 +21,6 @@ import { authenticateRequestRouteToken, type RouteTokenIdentity } from "../coder import { normalizeVmId } from "../coderouter/teamMachines"; import { VM_PRINCIPAL_LIVE_STATUSES, - type VmPrincipal, type VmPrincipalLiveStatus, type VmPrincipalResult, type VmPrincipalRow, diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index 42ca591e75e0..6ef5ff6d21f1 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { applyVmResourceUsage } from "./resourceUsage"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Either from "effect/Either"; @@ -60,6 +61,7 @@ import { VmNotFoundError, VmResizeInvalidError, VmResizePlanLimitError, + VmResizeInProgressError, VmOperationUnsupportedError, VmProviderOperationError, VmSnapshotNotFoundError, @@ -1861,6 +1863,17 @@ function deferLegacyResourceCandidate( type ResourceReservationWriter = NonNullable; type ResizeUnconfirmedWriter = NonNullable; +/** A provider resize is successful only if its resource claim is still current. */ +function confirmResizedResourceReservation( + write: ResourceReservationWriter, + input: Parameters[0], + providerVmId: string, +): Effect.Effect { + return write(input).pipe(Effect.flatMap((confirmed) => confirmed + ? Effect.void + : Effect.fail(new VmResizeInProgressError({ vmId: providerVmId })))); +} + function reservationFromLegacyProviderStats( stats: VMStats, existing: VmResourceReservation, @@ -2842,6 +2855,7 @@ export function getVmStats(input: { ); } return yield* providers.getStats(vm.provider, input.providerVmId).pipe( + Effect.map((stats) => applyVmResourceUsage(stats, vm.providerMetadata, input.providerVmId, Date.now())), Effect.mapError((error): VmWorkflowError => error), Effect.catchAll((error) => { if (!isProviderNotFoundError(error)) return Effect.fail(error); @@ -2921,7 +2935,7 @@ export function resizeVm(input: { const existingReservation = vmResourceReservationFromMetadata(vm.providerMetadata); const currentDiskMb = vmProviderResourceSize("diskMb", current.diskTotalMb) ?? existingReservation.diskMb; if (repo.setResourceReservation) { - yield* repo.setResourceReservation({ + yield* confirmResizedResourceReservation(repo.setResourceReservation, { id: vm.id, reservation: reservationFromLegacyProviderStats( updated, @@ -2932,7 +2946,7 @@ export function resizeVm(input: { ...(hasVmResourceReservationMetadata(vm.providerMetadata) ? { expectedReservation: existingReservation } : {}), - }); + }, input.providerVmId); } yield* repo.recordUsageEvent({ userId: input.userId, @@ -3068,11 +3082,11 @@ export function resizeVm(input: { : hasVmResourceReservationMetadata(vm.providerMetadata) ? existingReservation : undefined; - yield* repo.setResourceReservation({ + yield* confirmResizedResourceReservation(repo.setResourceReservation, { id: vm.id, reservation: confirmedReservation, ...(expectedReservation === undefined ? {} : { expectedReservation }), - }).pipe(Effect.asVoid); + }, input.providerVmId); } yield* repo.recordUsageEvent({ userId: input.userId, diff --git a/web/tests/freestyle-network-announcement.test.ts b/web/tests/freestyle-network-announcement.test.ts index 603e1046e8fb..a60a347fd21d 100644 --- a/web/tests/freestyle-network-announcement.test.ts +++ b/web/tests/freestyle-network-announcement.test.ts @@ -58,7 +58,9 @@ describe("Freestyle private network readiness", () => { }; const vm = { exec: async ({ command }: { command: string }) => { - events.push(command.startsWith("python3 -c ") ? "guest-network" : "guest-daemon"); + // Adapter, reporter, and hook preparation can add probes. This test + // guards publication/rollback ordering, not the number of setup execs. + if (command.startsWith("python3 -c ")) events.push("guest-network"); return { statusCode: 0, stdout: "", stderr: "" }; }, fs: { @@ -79,16 +81,13 @@ describe("Freestyle private network readiness", () => { const allocation = operation === "create" ? provider.create({ image: "sh-fixture", network: { id: "vpc-fixture" } }) : provider.restore("sh-fixture", { network: { id: "vpc-fixture" } }); - const preparation = operation === "restore" - ? ["allocated", "guest-daemon", "guest-daemon", "guest-daemon"] - : ["allocated", "guest-daemon"]; if (hasAddresses) { await allocation; events.push("published"); - expect(events).toEqual([...preparation, "guest-network", "published"]); + expect(events).toEqual(["allocated", "guest-network", "published"]); } else { await expect(allocation).rejects.toThrow(); - expect(events).toEqual([...preparation, "delete"]); + expect(events).toEqual(["allocated", "delete"]); } }); diff --git a/web/tests/vm-freestyle-provider.test.ts b/web/tests/vm-freestyle-provider.test.ts index 18e2d5978767..7c1e06675b63 100644 --- a/web/tests/vm-freestyle-provider.test.ts +++ b/web/tests/vm-freestyle-provider.test.ts @@ -316,9 +316,8 @@ describe("Freestyle platform contract", () => { expect(result.exitCode).toBe(0); expect(fake.writes).toHaveLength(1); expect(fake.writes[0]?.content).toBe(GUEST_CMUX_SHIM); - expect(fake.execs).toHaveLength(3); expect(fake.execs[1]).toContain(`mv -f`); - expect(fake.execs[2]).toBe("cmux self --json"); + expect(fake.execs.at(-1)).toBe("cmux self --json"); }); test("exec timeouts clamp to the per-exec cap; killed execs read as 124", () => { diff --git a/web/tests/vm-freestyle-stats.test.ts b/web/tests/vm-freestyle-stats.test.ts new file mode 100644 index 000000000000..565a246e34f1 --- /dev/null +++ b/web/tests/vm-freestyle-stats.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { Effect, Layer } from "effect"; +import { Freestyle } from "freestyle"; +import { FreestyleProvider } from "../services/vms/drivers/freestyle"; +import { VmBillingGateway, noOpVmBillingGateway } from "../services/vms/billingGateway"; +import { VmProviderGateway, type VmProviderGatewayShape } from "../services/vms/providerGateway"; +import { VmRepository, type VmRepositoryShape } from "../services/vms/repository"; +import { getVmStats } from "../services/vms/workflows"; +import { VM_RESOURCE_USAGE_KEY, applyVmResourceUsage, parseVmResourceUsage } from "../services/vms/resourceUsage"; + +const gauges = { cpuPercent: 37.5, memoryUsedMb: 1234, diskUsedMb: 5678 }; + +async function readStats(state: string, sample: Record | undefined) { + const calls: string[] = []; + const client = new Freestyle({ + apiKey: "test-only", + fetch: (async (input, init) => { + const path = new URL(String(input)).pathname; + calls.push(path); + if (path !== "/v5/vms/vm-stats" || init?.method !== "GET") throw new Error(`Guest touched: ${path}`); + return Response.json({ state, resources: { cpu: 2, memory: 4096, storage: 16384 } }); + }) as typeof fetch, + }); + const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("Unexpected install"); } }); + const repo = { + findUserVm: () => Effect.succeed({ provider: "freestyle", providerVmId: "vm-stats", billingTeamId: null, + providerMetadata: sample ? { [VM_RESOURCE_USAGE_KEY]: sample } : {} }), + } as unknown as VmRepositoryShape; + const providers = { + getStats: () => Effect.promise(() => provider.getStats("vm-stats")), + } as unknown as VmProviderGatewayShape; + const layer = Layer.mergeAll( + Layer.succeed(VmRepository, repo), Layer.succeed(VmProviderGateway, providers), + Layer.succeed(VmBillingGateway, noOpVmBillingGateway()), + ); + const result = await Effect.runPromise(getVmStats({ userId: "user", providerVmId: "vm-stats" }).pipe(Effect.provide(layer))); + expect(calls).toEqual(["/v5/vms/vm-stats"]); + return result; +} + +describe("Freestyle live machine stats", () => { + test("existing stats workflow returns the guest sample without executing in the VM", async () => { + const receivedAt = Date.now(); + const result = await readStats("running", { ...gauges, receivedAt, providerVmId: "vm-stats", diskTotalMb: 1 }); + expect(result).toEqual({ state: "awake", sampledAt: receivedAt, cpus: 2, memoryTotalMb: 4096, diskTotalMb: 16384, ...gauges }); + }); + + test.each(["paused", "pausing", "stopped", "starting"])("a %s machine never exposes an old reading or touches the guest", async (state) => { + const result = await readStats(state, { ...gauges, receivedAt: Date.now(), providerVmId: "vm-stats" }); + expect(result.state).toBe(state === "starting" ? "unknown" : "asleep"); + expect(result.cpuPercent).toBeUndefined(); + expect(result.memoryUsedMb).toBeUndefined(); + expect(result.diskUsedMb).toBeUndefined(); + }); + + test.each([undefined, { ...gauges, receivedAt: 0, providerVmId: "vm-stats" }, + { ...gauges, receivedAt: Date.now(), providerVmId: "replaced-vm" }])("missing, stale, and replaced-VM samples remain unavailable", async (sample) => { + const result = await readStats("running", sample); + expect(result.cpuPercent).toBeUndefined(); + expect(result.diskTotalMb).toBe(16384); + }); + + test("freshness boundary uses server time and retains partial zero readings", () => { + const base = { state: "awake" as const, sampledAt: 100000, diskTotalMb: 16384 }; + const metadata = { [VM_RESOURCE_USAGE_KEY]: { cpuPercent: 0, diskUsedMb: 0, receivedAt: 10000, providerVmId: "vm-stats" } }; + expect(applyVmResourceUsage(base, metadata, "vm-stats", 100000).cpuPercent).toBe(0); + expect(applyVmResourceUsage(base, metadata, "vm-stats", 100001).cpuPercent).toBeUndefined(); + expect(applyVmResourceUsage(base, metadata, "vm-stats", 9999).cpuPercent).toBeUndefined(); + }); + + test.each([null, [], {}, { cpuPercent: NaN }, { cpuPercent: 101 }, { memoryUsedMb: -1 }, { diskUsedMb: "20" }].map((value) => ({ value })))( + "rejects invalid gauges: %j", ({ value }) => expect(parseVmResourceUsage(value)).toBeNull(), + ); + test("guest fields cannot overwrite identity, capacity, or server time", () => { + expect(parseVmResourceUsage({ ...gauges, providerVmId: "other", receivedAt: 1, diskTotalMb: 1 })).toEqual(gauges); + }); +}); diff --git a/web/tests/vm-guest-resource-reporter.test.ts b/web/tests/vm-guest-resource-reporter.test.ts new file mode 100644 index 000000000000..585699d96c34 --- /dev/null +++ b/web/tests/vm-guest-resource-reporter.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { GUEST_RESOURCE_SAMPLE_SCRIPT, guestResourceReporterInstallCommand, guestResourceReporterScript } from "../services/vms/guestResourceReporter"; + +function runCounterFixture(cpu: string[], meminfo: string) { + const harness = `import builtins,io,json,os,sys,time,types +fixture=json.loads(sys.argv[1]) +counters=iter(fixture['cpu']) +def read(path): + if path == '/proc/stat': return io.StringIO(next(counters)) + if path == '/proc/meminfo': return io.StringIO(fixture['meminfo']) + raise AssertionError(path) +builtins.open=read +time.sleep=lambda duration: None +os.statvfs=lambda path: types.SimpleNamespace(f_blocks=10000,f_bfree=4000,f_bavail=3500,f_frsize=4096) +exec(sys.stdin.read()) +print(json.dumps(sample())) +`; + const result = spawnSync("python3", ["-c", harness, JSON.stringify({ cpu, meminfo })], { + input: GUEST_RESOURCE_SAMPLE_SCRIPT, encoding: "utf8", + }); + expect(result.status).toBe(0); + return JSON.parse(result.stdout); +} + +test("samples CPU deltas without double-counting guest ticks, reclaimable RAM, and root filesystem use", () => { + const stats = runCounterFixture([ + "cpu 100 0 50 800 50 0 0 0 80 0\n", + "cpu 120 0 60 850 70 0 0 0 90 0\n", + ], "MemTotal: 4194304 kB\nMemAvailable: 3145728 kB\nCached: 2000000 kB\n"); + expect(stats.cpuPercent).toBeCloseTo(30); + expect(stats.memoryUsedMb).toBe(1024); + expect(stats.diskUsedMb).toBe(23); +}); + +test("counter resets and incomplete memory counters leave those gauges missing", () => { + const stats = runCounterFixture(["cpu 100 0 50 800 50 0 0 0\n", "cpu 10 0 5 80 5 0 0 0\n"], "MemTotal: 4096 kB\n"); + expect(stats).toEqual({ diskUsedMb: 23 }); +}); + +test("the actual reporter posts the sample through the alias and then waits for its cadence", () => { + const harness = `import io,json,sys,time,urllib.request +class Finished(BaseException): pass +original_sleep=time.sleep +def sleep(duration): + if duration == 30: raise Finished() + original_sleep(duration) +time.sleep=sleep +def post(request,timeout,context): + print(json.dumps({'url':request.full_url,'method':request.method,'body':json.loads(request.data),'headers':dict(request.headers),'timeout':timeout})) + return io.BytesIO(b'') +urllib.request.urlopen=post +try: exec(sys.stdin.read()) +except Finished: pass +`; + const result = spawnSync("python3", ["-c", harness], { input: guestResourceReporterScript(), encoding: "utf8" }); + expect(result.status).toBe(0); + const sent = JSON.parse(result.stdout); + expect(sent.url).toBe("https://coderouter.cmux.internal/api/vm/resource-usage/self"); + expect(sent.method).toBe("POST"); + expect(sent.headers.Authorization).toBe("Bearer cmux-vm-edge-placeholder"); + expect(sent.timeout).toBe(5); + // Disk is available on macOS too; Linux additionally supplies CPU and RAM. + expect(sent.body.diskUsedMb).toBeGreaterThanOrEqual(0); +}); + +test("installer is a valid portable shell program", () => { + const result = spawnSync("sh", ["-n"], { input: guestResourceReporterInstallCommand(), encoding: "utf8" }); + expect(result.status).toBe(0); +}); diff --git a/web/tests/vm-publication-pool-db.test.ts b/web/tests/vm-publication-pool-db.test.ts index 1259c5580e19..bf1c935e2828 100644 --- a/web/tests/vm-publication-pool-db.test.ts +++ b/web/tests/vm-publication-pool-db.test.ts @@ -27,15 +27,16 @@ describe("publication authorization capacity", () => { await held; }); await ready; - let timer: ReturnType | undefined; try { - const completed = await Promise.race([ - publicationDatabaseRuntime().then(runtime => runtime.runPromise(Effect.flatMap(Database, db => db.execute(sql`select 1`)))).then(() => true), - new Promise(resolve => { timer = setTimeout(() => resolve(false), 1000); }), - ]); + const runtime = await publicationDatabaseRuntime(); + const completed = await runtime.runPromise( + Effect.flatMap(Database, db => db.execute(sql`select 1`)).pipe( + Effect.as(true), + Effect.timeout("1 second"), + ), + ); expect(completed).toBe(true); } finally { - clearTimeout(timer); release(); await background; } diff --git a/web/tests/vm-resource-reporter-install.test.ts b/web/tests/vm-resource-reporter-install.test.ts new file mode 100644 index 000000000000..f8309ae06857 --- /dev/null +++ b/web/tests/vm-resource-reporter-install.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import type { Freestyle } from "freestyle"; +import { FreestyleProvider } from "../services/vms/drivers/freestyle"; + +function fixture(cliFails = false) { + const deleted: string[] = []; + const commands: string[] = []; + const vmId = "vm-resource-reporter-test"; + const data = { id: vmId, state: "running", snapshotId: "sh-test", publicIpv6: "2602:f75c:0:1::2a", + resources: { cpu: 64, memory: 131072, storage: 1048576 }, vpcs: [{ ipv4: "10.16.0.2", ipv6: "fd00::2" }] }; + const vm = { + exec: async ({ command }: { command: string }) => { + commands.push(command); + const fails = command.includes("cmux-resource-stats.service") || (cliFails && command.includes("mv -f")); + return { statusCode: fails ? 1 : 0, stdout: "", stderr: fails ? "systemd unavailable" : "" }; + }, + fs: { writeTextFile: async () => {}, remove: async () => {} }, + delete: async () => { deleted.push(vmId); }, + }; + const client = { vms: { create: async () => ({ vm, vmId, data }), get: async () => data } } as unknown as Freestyle; + const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("No daemon install expected"); } }); + return { provider, deleted, commands }; +} + +describe("advisory resource reporter installation", () => { + test.each(["create", "restore"])("reporter failure does not roll back %s", async (operation) => { + const { provider, deleted, commands } = fixture(); + const network = { id: "vpc-resource-test" }; + const handle = operation === "create" ? await provider.create({ image: "sh-test", network }) : await provider.restore("sh-test", { network }); + expect(handle.providerVmId).toBe("vm-resource-reporter-test"); + expect(commands.some(command => command.includes("cmux-resource-stats.service"))).toBe(true); + expect(deleted).toEqual([]); + }); + + test("required CLI installation failure still rolls back the allocated machine", async () => { + const { provider, deleted } = fixture(true); + await expect(provider.create({ image: "sh-test" })).rejects.toThrow(); + expect(deleted).toEqual(["vm-resource-reporter-test"]); + }); +}); diff --git a/web/tests/vm-resource-usage-db.test.ts b/web/tests/vm-resource-usage-db.test.ts new file mode 100644 index 000000000000..bf683155622b --- /dev/null +++ b/web/tests/vm-resource-usage-db.test.ts @@ -0,0 +1,71 @@ +import { afterAll, beforeAll, expect, mock, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import postgres, { type Sql } from "postgres"; +import { closeCloudDbForTests } from "../db/client"; +import type { VmPrincipalRow } from "../services/vms/vmPrincipalContract"; + +const enabled = process.env.CMUX_DB_TEST === "1"; +const dbTest = enabled ? test : test.skip; +const id = randomUUID(); +const suffix = id.replaceAll("-", ""); +const audit = `cmux_resource_audit_${suffix}`; +const auditFunction = `${audit}_fn`; +let sql: Sql; +let principalRow: VmPrincipalRow | null = null; +let active = false; + +const principal = await import("../services/vms/vmPrincipal"); +const authenticate = principal.requireVmPrincipal; +mock.module("../services/vms/vmPrincipal", () => ({ + ...principal, + requireVmPrincipal: ((request: Request, deps?: Parameters[1]) => active && principalRow + ? Promise.resolve({ ok: true as const, principal: { vm: principalRow, userId: principalRow.userId, teamId: "resource-test" } }) + : authenticate(request, deps)) as typeof authenticate, +})); +const { POST } = await import("../app/api/vm/resource-usage/self/route"); + +beforeAll(async () => { + if (!enabled) return; + sql = postgres(process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL!, { max: 5 }); + await sql`insert into cloud_vms (id,user_id,provider,provider_vm_id,image_id,status,provider_metadata) + values (${id},'resource-test','freestyle',${`provider-${id}`},'sh-test','running','{"networkId":"preserved"}'::jsonb)`; + // Count actual durable row changes, not attempted SQL calls. Identifier and + // predicate values are generated UUIDs scoped to this test's isolated DB. + await sql.unsafe(`create table ${audit} (written integer)`); + await sql.unsafe(`create function ${auditFunction}() returns trigger language plpgsql as $$ begin + if NEW.id = '${id}'::uuid and NEW.provider_metadata->'cmuxResourceUsage' is distinct from OLD.provider_metadata->'cmuxResourceUsage' then + insert into ${audit} values (1); + end if; return NEW; end $$`); + await sql.unsafe(`create trigger ${audit} after update of provider_metadata on cloud_vms for each row execute function ${auditFunction}()`); + principalRow = await principal.loadCloudVmRow(id); + if (!principalRow) throw new Error("Missing test VM"); +}); + +afterAll(async () => { + active = false; + if (!enabled || !sql) return; + await sql.unsafe(`drop trigger if exists ${audit} on cloud_vms`); + await sql.unsafe(`drop function if exists ${auditFunction}()`); + await sql.unsafe(`drop table if exists ${audit}`); + await sql`delete from cloud_vms where id = ${id}`; + await closeCloudDbForTests(); + await sql.end(); +}); + +dbTest("concurrent authenticated reports make one durable write per VM window", async () => { + active = true; + try { + // Every request has the same pre-write principal snapshot, reproducing + // concurrent auth reads before the first update has committed. + const responses = await Promise.all(Array.from({ length: 8 }, (_, cpuPercent) => POST(new Request( + "https://cmux.test/api/vm/resource-usage/self", + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ cpuPercent }) }, + )))); + expect(responses.map(response => response.status)).toEqual(Array(8).fill(204)); + const [writes] = await sql`select count(*)::int as count from ${sql(audit)}`; + expect(writes.count).toBe(1); + const [row] = await sql`select provider_metadata from cloud_vms where id = ${id}`; + expect(row.provider_metadata.networkId).toBe("preserved"); + expect(row.provider_metadata.cmuxResourceUsage.providerVmId).toBe(`provider-${id}`); + } finally { active = false; } +}); diff --git a/web/tests/vm-resource-usage-ingest.test.ts b/web/tests/vm-resource-usage-ingest.test.ts new file mode 100644 index 000000000000..6a212a17b139 --- /dev/null +++ b/web/tests/vm-resource-usage-ingest.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { makeVmResourceUsageHandler } from "../services/vms/resourceUsageIngest"; +import { VM_RESOURCE_USAGE_KEY } from "../services/vms/resourceUsage"; +import { requireVmPrincipal } from "../services/vms/vmPrincipal"; +import type { VmPrincipalRow } from "../services/vms/vmPrincipalContract"; + +const vm: VmPrincipalRow = { id: "self", providerVmId: "provider-self", provider: "freestyle", userId: "owner", + billingTeamId: "team", billingPlanId: "pro", displayName: null, slug: null, imageId: "image", imageVersion: null, + status: "running", createdAt: new Date(0), providerMetadata: { networkId: "keep" } }; +function harness(metadata = vm.providerMetadata, failStore = false) { + const writes: unknown[] = []; + const handler = makeVmResourceUsageHandler({ + authenticate: (request) => requireVmPrincipal(request, { + authenticate: async (token) => token === "valid" ? { teamId: "team", stackUserId: "owner", vmId: "self" } : null, + loadVm: async () => ({ ...vm, providerMetadata: metadata }), + }), + now: () => 100000, + accept: async (machine, usage, receivedAt) => { + if (failStore) throw new Error("DB unavailable"); + writes.push({ id: machine.id, usage, receivedAt }); + }, + }); + return { handler, writes }; +} +function request(body: unknown, token = "valid", id = "self") { + return new Request("https://cmux.test/api/vm/resource-usage/self", { method: "POST", + headers: { "x-coderouter-route-token": token, "x-cmux-vm-id": id, "content-type": "application/json" }, + body: JSON.stringify(body) }); +} +test("edge-authenticated report writes only the caller's gauges and server timestamp", async () => { + const { handler, writes } = harness(); + expect((await handler(request({ cpuPercent: 20, memoryUsedMb: 1024, diskUsedMb: 4096, vmId: "other", receivedAt: 1 }))).status).toBe(204); + expect(writes).toEqual([{ id: "self", usage: { cpuPercent: 20, memoryUsedMb: 1024, diskUsedMb: 4096 }, receivedAt: 100000 }]); +}); +test.each([["bad", "self"], ["valid", "other"]])("rejects absent/forged guest identity", async ([token, id]) => { + const { handler, writes } = harness(); + expect((await handler(request({ cpuPercent: 50 }, token, id))).status).toBe(401); + expect(writes).toEqual([]); +}); +test.each([{ cpuPercent: 101 }, { memoryUsedMb: -1 }, {}, { diskUsedMb: "3" }])("rejects invalid report %j", async (body) => { + const { handler, writes } = harness(); + expect((await handler(request(body))).status).toBe(400); + expect(writes).toEqual([]); +}); +test("rejects oversized streamed bodies", async () => { + const { handler, writes } = harness(); + expect((await handler(request({ padding: "x".repeat(2000), cpuPercent: 1 }))).status).toBe(413); + expect(writes).toEqual([]); +}); +test("delegates throttle eligibility to storage rather than the auth snapshot", async () => { + const { handler, writes } = harness({ [VM_RESOURCE_USAGE_KEY]: { receivedAt: 99000 } }); + expect((await handler(request({ cpuPercent: 0 }))).status).toBe(204); + expect(writes).toHaveLength(1); +}); +test("a failed durable write is retryable and never acknowledged", async () => { + const { handler } = harness({}, true); + expect((await handler(request({ cpuPercent: 0 }))).status).toBe(503); +}); diff --git a/web/tests/vm-workflows.test.ts b/web/tests/vm-workflows.test.ts index 96fafb89a1fd..1d42480a275d 100644 --- a/web/tests/vm-workflows.test.ts +++ b/web/tests/vm-workflows.test.ts @@ -585,6 +585,53 @@ describe("VM Effect workflows", () => { expect(usageEvents[0]?.eventType).toBe("vm.resize"); }); + for (const storageMb of [undefined, 65536]) { + test.each([true, false])(`reservation compare-and-set controls ${storageMb ? "combined" : "compute"} resize success: %s`, async (committed) => { + const vm = testCloudVmRow({ + userId: "resize-reservation-race", providerVmId: "provider-reservation-race", + status: "running", billingPlanId: "max", + providerMetadata: { cmuxResourceReservation: { vcpus: 2, memoryMb: 4096, diskMb: 32768 } }, + }); + const usageEvents: RecordedUsageEvent[] = []; + const confirmations: Parameters>[0][] = []; + let resized = false; + const repo: VmRepositoryShape = { + ...testWorkflowRepo({ vm, usageEvents }), + setResourceReservation: (confirmation) => Effect.sync(() => { + expect(resized).toBe(true); + confirmations.push(confirmation); + return committed; + }), + }; + const provider: VmProviderGatewayShape = { + ...unusedProviderGateway(), + getStatus: () => Effect.succeed("running"), + getStats: () => Effect.sync(() => ({ + state: "awake", sampledAt: 1780000000000, + cpus: resized ? 4 : 2, memoryTotalMb: resized ? 8192 : 4096, + diskTotalMb: resized ? storageMb ?? 32768 : 32768, + })), + resize: () => Effect.sync(() => { resized = true; }), + }; + const result = await Effect.runPromise(resizeVm({ + userId: vm.userId, teamIds: [vm.billingTeamId!], providerVmId: vm.providerVmId!, + billingPlanId: "max", cpu: 4, memoryMb: 8192, storageMb, + }).pipe(Effect.either, Effect.provide(workflowLayer(repo, provider)))); + expect(confirmations).toEqual([{ + id: vm.id, + reservation: { vcpus: 4, memoryMb: 8192, diskMb: storageMb ?? 32768 }, + expectedReservation: { vcpus: 2, memoryMb: 4096, diskMb: 32768 }, + }]); + if (committed) { + expect(result).toMatchObject({ _tag: "Right", right: { cpus: 4, memoryTotalMb: 8192 } }); + expect(usageEvents).toHaveLength(1); + } else { + expect(result).toMatchObject({ _tag: "Left", left: { _tag: "VmResizeInProgressError", vmId: vm.providerVmId } }); + expect(usageEvents).toHaveLength(0); + } + }); + } + test("persists a provider-rounded disk claim after a paid resize", async () => { const vm = testCloudVmRow({ id: "00000000-0000-4000-8000-000000000142",