Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public struct MachineSnapshot: Equatable, Identifiable, Sendable {
capabilities: VMCapabilities = .all,
activity: Activity,
createdAt: Date? = nil,
createdBy: VMCreator? = nil,
label: String? = nil,
slug: String? = nil,
freeAccess: FreeAccessState = .unrestricted,
Expand All @@ -26,6 +27,7 @@ public struct MachineSnapshot: Equatable, Identifiable, Sendable {
self.capabilities = capabilities
self.activity = activity
self.createdAt = createdAt
self.createdBy = createdBy
self.label = label
self.slug = slug
self.freeAccess = freeAccess
Expand Down Expand Up @@ -66,6 +68,9 @@ public struct MachineSnapshot: Equatable, Identifiable, Sendable {
public var capabilities: VMCapabilities = .all
public let activity: Activity
public let createdAt: Date?
/// Who made this machine; nil for machines the surface catalog discovered
/// on its own and on control planes that do not send an author.
public let createdBy: VMCreator?
/// User-chosen label; nil when the machine has no label.
public let label: String?
/// Server-generated three-word name; nil for machines older than naming.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public enum MachineSnapshotBuilder: Sendable {
capabilities: summary.capabilities,
activity: activity(fromStatus: summary.status),
createdAt: createdAt,
createdBy: summary.createdBy,
label: summary.displayName,
slug: summary.slug,
freeAccess: freeAccess,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,8 @@ public struct VMSummary: Sendable {
freeAccessExpiresAt: Int64? = nil,
addressIPv4: String? = nil,
addressIPv6: String? = nil,
cmuxTuiContract: String? = nil
cmuxTuiContract: String? = nil,
createdBy: VMCreator? = nil
) {
self.id = id
self.provider = provider
Expand All @@ -296,6 +297,7 @@ public struct VMSummary: Sendable {
self.addressIPv4 = addressIPv4
self.addressIPv6 = addressIPv6
self.cmuxTuiContract = cmuxTuiContract
self.createdBy = createdBy
}

public let id: String
Expand All @@ -310,6 +312,9 @@ public struct VMSummary: Sendable {
public var capabilities: VMCapabilities = .all
/// User-chosen label; the id stays the machine's address.
public var displayName: String?
/// Who made this machine (`GET /api/vm` → `createdBy`). Nil when the
/// control plane does not send one. Display only; see ``VMCreator``.
public var createdBy: VMCreator?
/// Server-generated three-word name (`sleepy-teal-otter`), fixed for the
/// machine's life and unique among the owner's live machines. Nil on
/// machines created before the backend assigned names.
Expand Down Expand Up @@ -1158,6 +1163,7 @@ public actor VMClient {
summary.displayName = label
}
summary.slug = (dict["slug"] as? String).flatMap { $0.isEmpty ? nil : $0 }
summary.createdBy = VMCreator(vmResponse: dict)
summary.freeAccessExpiresAt = Self.epochMilliseconds(dict["freeAccessExpiresAt"])
if let address = dict["address"] as? [String: Any] {
summary.addressIPv4 = (address["ipv4"] as? String).flatMap { $0.isEmpty ? nil : $0 }
Expand Down Expand Up @@ -1587,6 +1593,14 @@ public actor VMClient {
summary.capabilities = VMCapabilities(vmResponse: obj)
summary.displayName = (obj["displayName"] as? String).flatMap { $0.isEmpty ? nil : $0 }
summary.slug = (obj["slug"] as? String).flatMap { $0.isEmpty ? nil : $0 }
// `create` has one caller today, the `vm.create` socket method, so
// this is what `cmux vm new --json` prints. The sidebar does not
// read it: the panel only ever assigns a whole `listPage()` result,
// so a created machine shows its author on the next list refresh
// and not before. Decoded here anyway because the field is in the
// response and a client that did merge this into the row it already
// listed would otherwise blank the author out.
summary.createdBy = VMCreator(vmResponse: obj)
// The create receipt names the new machine's private address and
// attach contract, so the app can register and dial it without a
// fleet re-read or an attach request (see createdMachineAttach).
Expand Down Expand Up @@ -1668,6 +1682,11 @@ public actor VMClient {
summary.displayName = label
}
summary.slug = (obj["slug"] as? String).flatMap { $0.isEmpty ? nil : $0 }
// Same as the create site: `status(id:)`'s one caller is the
// `vm.status` socket method, so this feeds `cmux vm status --json`.
// The panel's per-machine refresh goes through `SurfaceCatalog`,
// not through here.
summary.createdBy = VMCreator(vmResponse: obj)
if let address = obj["address"] as? [String: Any] {
summary.addressIPv4 = (address["ipv4"] as? String).flatMap { $0.isEmpty ? nil : $0 }
summary.addressIPv6 = (address["ipv6"] as? String).flatMap { $0.isEmpty ? nil : $0 }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

/// Who made a Cloud machine, for display only.
///
/// `/api/vm` is scoped by owner team, so on a team every member sees every
/// member's machines. Without this the sidebar shows a pile of generated
/// three-word names with nothing to tell them apart by.
///
/// `userId` is the stable part and is always present. `displayName` is nil
/// when nothing has recorded a name for that account: the backend writes its
/// identity snapshot when an account resolves and deletes it when that account
/// revokes a lease, so a teammate who just revoked one reads as unnamed until
/// they next sign in. The id is kept in that case so rows by the same person
/// still group together instead of collapsing into one anonymous bucket.
///
/// Nothing here is an authorization input. The caller is already entitled to
/// every machine it was sent.
public struct VMCreator: Equatable, Hashable, Sendable {
public init(userId: String, displayName: String? = nil) {
self.userId = userId
self.displayName = displayName
}

public let userId: String

/// Nil when no name is known. Never the raw account id: an opaque id in
/// place of a name is the same unreadable list this is meant to fix.
public let displayName: String?
}

extension VMCreator {
/// Reads `createdBy` out of one `GET /api/vm` item.
///
/// Returns nil for a missing or blank id, which covers both a control
/// plane that predates the field and a malformed entry. An author is
/// decoration on a row, so a bad one is dropped rather than failing the
/// whole list decode the way a missing `id` or `provider` does.
public init?(vmResponse: [String: Any]) {
guard let raw = vmResponse["createdBy"] as? [String: Any] else { return nil }
guard let userId = (raw["userId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!userId.isEmpty
else { return nil }
let displayName = (raw["displayName"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
self.init(
userId: userId,
displayName: displayName.flatMap { $0.isEmpty ? nil : $0 }
)
}
}
17 changes: 16 additions & 1 deletion Sources/Cloud/VMClientSocketCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,9 @@ extension TerminalController {
return .success(kind)
}

private nonisolated static func socketWorkerVMSummaryPayload(_ vm: VMSummary) -> [String: Any] {
/// Internal rather than private so `CloudMachineCreatorTests` can check
/// that a relayed client is sent the same machine facts a direct one gets.
nonisolated static func socketWorkerVMSummaryPayload(_ vm: VMSummary) -> [String: Any] {
var payload: [String: Any] = [
"id": vm.id,
"provider": vm.provider,
Expand Down Expand Up @@ -808,6 +810,19 @@ extension TerminalController {
if let slug = vm.slug, !slug.isEmpty {
payload["slug"] = slug
}
if let createdBy = vm.createdBy {
// A known account with no recorded name sends an explicit null for
// the name, the same shape the HTTP response uses, so a consumer
// written against `/api/vm` decodes this without a second case.
// No author at all sends no key, where the backend sends
// `"createdBy": null`; both live readers treat absent and null the
// same, so this is narrower than the wire format rather than a
// second meaning.
payload["createdBy"] = [
"userId": createdBy.userId,
"displayName": createdBy.displayName.map { $0 as Any } ?? NSNull(),
]
}
if let freeAccessExpiresAt = vm.freeAccessExpiresAt {
payload["freeAccessExpiresAt"] = freeAccessExpiresAt
}
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,7 @@
C12542000000000000000002 /* CloudInitialWorkspaceNamingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12542000000000000000001 /* CloudInitialWorkspaceNamingTests.swift */; };
8E7B97505657BF3E66091D9B /* CloudLinkRetryBackoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5E22859B10B563032FECE7 /* CloudLinkRetryBackoffTests.swift */; };
7A0CE1000000000000000732 /* CloudLoopbackPortForwardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000731 /* CloudLoopbackPortForwardTests.swift */; };
A44FBE43FCD42CFEEB6EAF6F /* CloudMachineCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6CE8A534885E9CE99D3F84 /* CloudMachineCreatorTests.swift */; };
9E21585251BFCA0BAF06B10B /* CloudMachineDeleteOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DEADB23282C5C0D06630294 /* CloudMachineDeleteOptimismTests.swift */; };
A13086000000000000000002 /* CloudMachineDragSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A13086000000000000000001 /* CloudMachineDragSourceTests.swift */; };
C6428DFF1BF84994BE7F09B1 /* CloudMachineLoadingReservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D98692372B4596B873883C /* CloudMachineLoadingReservation.swift */; };
Expand Down Expand Up @@ -5035,6 +5036,7 @@
C12542000000000000000001 /* CloudInitialWorkspaceNamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudInitialWorkspaceNamingTests.swift"; sourceTree = "<group>"; };
FF5E22859B10B563032FECE7 /* CloudLinkRetryBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudLinkRetryBackoffTests.swift"; sourceTree = "<group>"; };
7A0CE1000000000000000731 /* CloudLoopbackPortForwardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudLoopbackPortForwardTests.swift; sourceTree = "<group>"; };
9E6CE8A534885E9CE99D3F84 /* CloudMachineCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachineCreatorTests.swift"; sourceTree = "<group>"; };
5DEADB23282C5C0D06630294 /* CloudMachineDeleteOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachineDeleteOptimismTests.swift"; sourceTree = "<group>"; };
A13086000000000000000001 /* CloudMachineDragSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineDragSourceTests.swift; sourceTree = "<group>"; };
95D98692372B4596B873883C /* CloudMachineLoadingReservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineLoadingReservation.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -12667,6 +12669,7 @@
311C986A1E7430418708AAA6 /* MachinesListStatusToolbarRowTests.swift */,
1C38B9AFEEE4FAE6089C250F /* CloudTreeRowToolTipTests.swift */,
6F27EA9972F21B98FB4A7312 /* SurfaceCatalogObservationTests.swift */,
9E6CE8A534885E9CE99D3F84 /* CloudMachineCreatorTests.swift */,
C4C4607FC6F461CC8B35343E /* AgentHibernationBackgroundWorkTests.swift */,
A29C9B70624D86CFFD27EFEE /* CmuxEventSequenceStoreTests.swift */,
BCAE531397347899AA9F4E4E /* CloudCursorlessSnapshotTests.swift */,
Expand Down Expand Up @@ -16572,6 +16575,7 @@
C12542000000000000000002 /* CloudInitialWorkspaceNamingTests.swift in Sources */,
8E7B97505657BF3E66091D9B /* CloudLinkRetryBackoffTests.swift in Sources */,
7A0CE1000000000000000732 /* CloudLoopbackPortForwardTests.swift in Sources */,
A44FBE43FCD42CFEEB6EAF6F /* CloudMachineCreatorTests.swift in Sources */,
9E21585251BFCA0BAF06B10B /* CloudMachineDeleteOptimismTests.swift in Sources */,
A13086000000000000000002 /* CloudMachineDragSourceTests.swift in Sources */,
62D6740CB59ACBE3EED47A27 /* CloudMachineNotificationDeliveryTests.swift in Sources */,
Expand Down
Loading
Loading