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
13 changes: 11 additions & 2 deletions .github/workflows/cmux-next-defaults-guard-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,17 @@ jobs:
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
run_id="$(gh api "repos/$REPO/actions/workflows/cmux-next-defaults-guard.yml/runs?event=pull_request_target&head_sha=$HEAD_SHA&per_page=20" \
--jq '.workflow_runs[0].id // empty')"
error_file="$(mktemp)"
trap 'rm -f "$error_file"' EXIT
if ! run_id="$(gh api "repos/$REPO/actions/workflows/cmux-next-defaults-guard.yml/runs?event=pull_request_target&head_sha=$HEAD_SHA&per_page=20" \
--jq '.workflow_runs[0].id // empty' 2>"$error_file")"; then
if grep -Eq 'HTTP 404|404 Not Found' "$error_file"; then
echo "No defaults guard run on $HEAD_SHA yet; the runs endpoint returned 404."
exit 0
fi
cat "$error_file" >&2
exit 1
fi
if [[ -z "$run_id" ]]; then
echo "No defaults guard run on $HEAD_SHA yet; it reads the reviews when it runs."
exit 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,22 @@ extension DaemonConnection {
}

@discardableResult
public func renameWorkspace(_ key: WorkspaceKey, to name: String) async throws -> WorkspaceMutationResult {
try await request(RenameWorkspaceRequest(workspace: .key(key), name: name, mutation: mutation()))
public func renameWorkspace(_ key: WorkspaceKey, to name: String, source: WorkspaceNameSource? = nil,
expectedNameRevision: UInt64? = nil, release: Bool = false) async throws -> WorkspaceMutationResult {
if source == .auto {
guard identity?.supports(DaemonCapabilities.shared.workspaceNameSource) == true else {
throw DaemonError.missingCapabilities([DaemonCapabilities.shared.workspaceNameSource])
}
}
let nameMutation: MutationIdentity = {
guard source == .auto else { return mutation() }
var identity = mutation()
identity.expectedGeneration = self.identity?.generation
return identity
}()
return try await request(RenameWorkspaceRequest(workspace: .key(key), name: name, source: source,
expectedNameRevision: expectedNameRevision, release: release,
mutation: nameMutation))
}

@discardableResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public struct DaemonCapabilities: Sendable {
/// a daemon lacks them (`DaemonIdentity.supports`).
public let workspaceGroups = "workspace-groups-v1"
public let workspaceMetadata = "workspace-metadata-v1"
/// Workspace name provenance and revision (`name_source`, `name_revision`).
public let workspaceNameSource = "workspace-name-source-v1"
public let tabMetadata = "tab-metadata-v1"
public let frontendBrowserTabs = "frontend-browser-tabs-v1"
/// A frontend browser tab's back/forward entries and scroll positions,
Expand Down Expand Up @@ -390,7 +392,7 @@ public struct DaemonCapabilities: Sendable {
/// scripts/cmux-next/check-daemon-capabilities.sh fails the build when it
/// does not serve one of these or `required`. The GUI still checks each
/// with `DaemonIdentity.supports`, for remote and older daemons.
public var optional: [String] { [workspaceGroups, workspaceMetadata, tabMetadata, frontendBrowserTabs, tabDrag,
public var optional: [String] { [workspaceGroups, workspaceMetadata, workspaceNameSource, tabMetadata, frontendBrowserTabs, tabDrag,
notificationAck, tabGroups, savedTabGroups, terminalEnv, terminalPlacementEnv,
terminalReap, terminalReaperActive, batchClose, closeReason, closeWorkspaceIfEmpty, browserHostProvider, frontendBrowserActivate, frontendBrowserInsertAfter, loopbackForward, browserRuntime, screenMetadata, screenGroups, profiles, personalRemoteLocalhost,
terminalPendingSequence, personalTerminals, browserProfiles, notificationSource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,28 @@ public struct RenameWorkspaceRequest: DaemonRequest {
public static let command = "rename-workspace"
public var workspace: WorkspaceRef
public var name: String
public var source: WorkspaceNameSource?
public var expectedNameRevision: UInt64?
public var release: Bool
public var mutation: MutationIdentity?

public init(workspace: WorkspaceRef, name: String, mutation: MutationIdentity?) {
public init(workspace: WorkspaceRef, name: String, source: WorkspaceNameSource? = nil,
expectedNameRevision: UInt64? = nil, release: Bool = false, mutation: MutationIdentity?) {
self.workspace = workspace
self.name = name
self.source = source
self.expectedNameRevision = expectedNameRevision
self.release = release
self.mutation = mutation
}

enum CodingKeys: String, CodingKey { case name }
enum CodingKeys: String, CodingKey { case name, source, expectedNameRevision = "expected_name_revision", release }
public func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(name, forKey: .name)
try c.encodeIfPresent(source, forKey: .source)
if let expectedNameRevision { try c.encode(String(expectedNameRevision), forKey: .expectedNameRevision) }
if release { try c.encode(true, forKey: .release) }
try WorkspaceRefFields(ref: workspace).encode(to: encoder)
try MutationFields(identity: mutation).encode(to: encoder)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public final class WorkspaceModel: Identifiable {
/// Durable resource id (`ws_…`) on registry daemons.
public internal(set) var resourceID: ResourceID?
public internal(set) var name: String
public internal(set) var nameSource: WorkspaceNameSource
public internal(set) var nameRevision: UInt64
public internal(set) var screens: [ScreenModel]
/// Screen group runs in screen order (`screen-groups-v1`).
public internal(set) var screenGroups: [ScreenGroupSnapshot]
Expand Down Expand Up @@ -64,6 +66,8 @@ public final class WorkspaceModel: Identifiable {
handle = s.id
resourceID = s.resourceID
name = s.name
nameSource = s.nameSource
nameRevision = s.nameRevision
screens = s.screens.map(ScreenModel.init)
screenGroups = s.screenGroups
group = s.group
Expand All @@ -86,6 +90,8 @@ public final class WorkspaceModel: Identifiable {
if resourceID != s.resourceID { resourceID = s.resourceID }
if handle != s.id { handle = s.id }
if name != s.name { name = s.name }
if nameSource != s.nameSource { nameSource = s.nameSource }
if nameRevision != s.nameRevision { nameRevision = s.nameRevision }
if group != s.group { group = s.group }
if color != s.color { color = s.color }
if icon != s.icon { icon = s.icon }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

/// The authority that supplied a workspace's current name.
public enum WorkspaceNameSource: String, Codable, Sendable, Hashable {
/// A person or a legacy daemon supplied the name.
case user
/// An automatic agent naming pass supplied the name.
case auto
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ public struct WorkspaceSnapshot: Sendable, Hashable, Decodable {
public var resourceID: ResourceID?
public var shortID: String?
public var name: String
/// Who supplied `name`; missing on older daemons and conservatively user-owned.
public var nameSource: WorkspaceNameSource
/// The workspace-local name revision; missing on older daemons and treated as zero.
public var nameRevision: UInt64
/// Shared compatibility default, not user focus. Keep focus client-local.
public var active: Bool
public var screens: [ScreenSnapshot]
Expand Down Expand Up @@ -46,6 +50,8 @@ public struct WorkspaceSnapshot: Sendable, Hashable, Decodable {
resourceID: ResourceID? = nil,
shortID: String? = nil,
name: String,
nameSource: WorkspaceNameSource = .user,
nameRevision: UInt64 = 0,
active: Bool = false,
screens: [ScreenSnapshot] = [],
group: WorkspaceGroupID? = nil,
Expand All @@ -61,6 +67,8 @@ public struct WorkspaceSnapshot: Sendable, Hashable, Decodable {
self.resourceID = resourceID
self.shortID = shortID
self.name = name
self.nameSource = nameSource
self.nameRevision = nameRevision
self.active = active
self.screens = screens
self.group = group
Expand All @@ -74,6 +82,8 @@ public struct WorkspaceSnapshot: Sendable, Hashable, Decodable {

enum CodingKeys: String, CodingKey {
case id, key, name, active, screens, group, color, icon, title, pinned, kind, app
case nameSource = "name_source"
case nameRevision = "name_revision"
case markedUnread = "marked_unread"
case resourceID = "resource_id"
case shortID = "short_id"
Expand All @@ -88,6 +98,12 @@ public struct WorkspaceSnapshot: Sendable, Hashable, Decodable {
resourceID = try c.decodeIfPresent(ResourceID.self, forKey: .resourceID)
shortID = try c.decodeIfPresent(String.self, forKey: .shortID)
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
nameSource = try c.decodeIfPresent(WorkspaceNameSource.self, forKey: .nameSource) ?? .user
if let revision = try? c.decode(String.self, forKey: .nameRevision) {
nameRevision = UInt64(revision) ?? 0
} else {
nameRevision = (try? c.decode(UInt64.self, forKey: .nameRevision)) ?? 0
}
active = try c.decodeIfPresent(Bool.self, forKey: .active) ?? false
screens = try c.decodeIfPresent([ScreenSnapshot].self, forKey: .screens) ?? []
group = try c.decodeIfPresent(WorkspaceGroupID.self, forKey: .group)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Foundation
import Testing
@testable import CmuxNextDaemon

struct WorkspaceNameSourceTests {
@Test func legacySnapshotDefaultsToManualOwnership() throws {
let snapshot = try Self.decodeSnapshot("""
{"id":1,"key":"legacy","name":"Claude Code","screens":[]}
""")

#expect(snapshot.nameSource == .user)
#expect(snapshot.nameRevision == 0)
}

@Test func snapshotDecodesAutomaticOwnershipAndDecimalRevision() throws {
let snapshot = try Self.decodeSnapshot("""
{"id":1,"key":"auto","name":"Deploy API","name_source":"auto","name_revision":"7","screens":[]}
""")

#expect(snapshot.nameSource == .auto)
#expect(snapshot.nameRevision == 7)
}

@Test @MainActor func modelUpdateCarriesNameAuthority() throws {
let initial = try Self.decodeSnapshot("""
{"id":1,"key":"model","name":"Claude Code","name_source":"user","name_revision":"2","screens":[]}
""")
let model = WorkspaceModel(initial)
let updated = try Self.decodeSnapshot("""
{"id":1,"key":"model","name":"Deploy API","name_source":"auto","name_revision":"3","screens":[]}
""")

model.update(updated)

#expect(model.nameSource == .auto)
#expect(model.nameRevision == 3)
#expect(model.displayName == "Deploy API")
}

@Test func manualRenameRequestKeepsLegacyWireShape() throws {
let request = RenameWorkspaceRequest(workspace: .key(WorkspaceKey(rawValue: "manual")), name: "Manual", mutation: nil)
let object = try Self.object(request)

#expect(object["name"] as? String == "Manual")
#expect(object["source"] == nil)
#expect(object["expected_name_revision"] == nil)
#expect(object["release"] == nil)
}

@Test func automaticRenameRequestUsesDecimalRevision() throws {
let request = RenameWorkspaceRequest(workspace: .key(WorkspaceKey(rawValue: "auto")), name: "Deploy API",
source: .auto, expectedNameRevision: 7, release: false, mutation: nil)
let object = try Self.object(request)

#expect(object["source"] as? String == "auto")
#expect(object["expected_name_revision"] as? String == "7")
#expect(object["release"] == nil)
}

private static func decodeSnapshot(_ json: String) throws -> WorkspaceSnapshot {
try JSONDecoder().decode(WorkspaceSnapshot.self, from: Data(json.utf8))
}

private static func object<T: Encodable>(_ value: T) throws -> [String: Any] {
try JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as! [String: Any]
}
}
1 change: 1 addition & 0 deletions plans/cmux-next/daemon-capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"workspace-groups-v1",
"workspace-kind-v1",
"workspace-metadata-v1",
"workspace-name-source-v1",
"workspace-pin-v1",
"ws-listen-origins-v1",
"ws-listen-v1"
Expand Down
Loading