From 1f76af3a76ea55bdd2408ae5674b4723c8934f1a Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Wed, 7 Oct 2026 22:57:10 +0100 Subject: [PATCH 1/4] feat(macapp): remote workrooms in every build, no preview setting Remote workrooms were Nightly and Dev only, and there only with the hidden remoteWorkroomsPreview setting, until #260's success criteria passed. The owner has lifted that gate: every build, stable included, now offers New Workroom's places (This Mac, local containers, boxd), opens and deletes remote workrooms, adopts their hosts at launch, and shows the Remote workrooms section in Settings. - Drop RemoteWorkrooms.isEnabled, its enabledForTesting seam, the remoteWorkroomsPreview key and the -WorkroomUITestRemotePreview fixture flag. - The row's "+" is always the places menu, so CreateRowButton loses its plain-button form. - Tests that turned the gate off now make the workroom unreachable the real ways: another build's host, or one still being created. The "turned off in this build" note and the preview-off UI test go. - Design docs and macapp/AGENTS.md say the gate is gone; As-built sections are left as the record they are. Co-Authored-By: Claude Opus 5.5 --- docs/designs/oq8-cross-machine-reattach.md | 3 +- docs/designs/remote-workrooms.md | 14 ++-- macapp/AGENTS.md | 2 +- macapp/WorkroomApp/Core/AppStore.swift | 10 +-- macapp/WorkroomApp/Core/DefaultsKeys.swift | 6 -- macapp/WorkroomApp/Core/Models.swift | 11 +-- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 17 ---- macapp/WorkroomApp/Core/UITestFixture.swift | 9 --- .../WorkroomApp/Views/NewWorkroomDialog.swift | 11 +-- macapp/WorkroomApp/Views/ProjectSidebar.swift | 80 ++++++------------- macapp/WorkroomApp/Views/SettingsView.swift | 6 +- .../AppStoreDeleteRaceTests.swift | 2 - .../WorkroomAppTests/RemoteHostsTests.swift | 10 +-- .../RemoteProvisioningIntegrationTests.swift | 3 - .../RemoteWorkroomRoutingTests.swift | 6 -- .../SessionRestoreTests.swift | 37 ++------- .../TerminalTargetTests.swift | 17 +--- .../NewWorkroomPlacesUITests.swift | 37 +++------ 18 files changed, 72 insertions(+), 209 deletions(-) diff --git a/docs/designs/oq8-cross-machine-reattach.md b/docs/designs/oq8-cross-machine-reattach.md index 2fe3e1347..ee2e2673d 100644 --- a/docs/designs/oq8-cross-machine-reattach.md +++ b/docs/designs/oq8-cross-machine-reattach.md @@ -277,7 +277,8 @@ Codex (gpt-6.1-sol, cold read of the repo): No new artifact. The agent ships inside the app (Mac and static Linux builds) and is pushed to a host on a version mismatch (#231), so a new protocol version reaches hosts through the existing -bootstrap. The remote UI stays behind `remoteWorkroomsPreview` in Nightly and Dev until #260. +bootstrap. The remote UI stayed behind `remoteWorkroomsPreview` in Nightly and Dev until 2026-10-07, when +the owner removed that gate. ## Next Steps diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index 19fccb2b1..0c27fb143 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -11,9 +11,11 @@ Mode: Builder ## Current Status — 2026-10-06 -**Phase 4 is under way, and the app can now create, open and delete remote workrooms.** That works -only in a Nightly or Dev build with the hidden `remoteWorkroomsPreview` setting, and only on the -container driver (the Mac's own Docker, from the `workroom-host` image). Merged so far: host +**Phase 4 is under way, and the app can now create, open and delete remote workrooms.** Since +2026-10-07 that works in every build, stable included: the owner removed the Nightly-only gate and +the hidden `remoteWorkroomsPreview` setting before #260's success criteria were run. At #253 it +worked only on the container driver (the Mac's own Docker, from the `workroom-host` image). Merged +so far: host descriptors in config (#249, PR #275, 2026-10-01); the credential broker service (#250, in joelmoss/codaset#43, 2026-10-01) and its clients (#251, PR #263, 2026-09-30); portable derivation on the container driver (#252, PR #280, 2026-10-01); the boxd driver with portable derivation (#256, PR @@ -3904,8 +3906,10 @@ service milestones below so each layer can be reviewed and landed independently. first, including identity/key isolation and cleanup of instances and credentials after partial failure. Apply the measured wakefulness policy through the far-side shim (#257: through the agent's own heartbeat instead). Add boxd's fast - derivation only after the portable path passes. Remote UI remains Nightly-only until the success - criteria pass; the container fixture alone does not establish parity across two real providers. + derivation only after the portable path passes. ~~Remote UI remains Nightly-only until the success + criteria pass~~ — the owner lifted that gate on 2026-10-07, ahead of #260, which now verifies + the criteria after the fact; the container fixture alone does not establish parity across two + real providers. **Filed 2026-09-27** as #249 (host descriptors; built 2026-10-01, PR #275), #250 (broker service; built 2026-10-01, joelmoss/codaset#43) → #251 (broker clients; built 2026-09-30, PR #263) → #252 (portable derivation on the container driver; built 2026-10-01, PR #280) → #253 (remote workrooms diff --git a/macapp/AGENTS.md b/macapp/AGENTS.md index 81b51e2a9..6c12b3c55 100644 --- a/macapp/AGENTS.md +++ b/macapp/AGENTS.md @@ -203,7 +203,7 @@ side is `Core/Broker/`: - `AgentEnrolment.swift`: creates a grant and runs `wr-agent enrol` over `HostDriver.exec`, code on stdin. The agent side is `wr-agent enrol` / `wr-agent credential` (`vcs/crates/wr-agent/src/ broker.rs`), git's credential helper on the remote host. -- `BrokerSession.swift` backs a Settings ▸ General row hidden behind `remoteWorkroomsPreview`. +- `BrokerSession.swift` backs the Settings ▸ General ▸ Remote workrooms row. `BrokerEndpoint` decides which Codaset a build talks to. Release and Nightly use codaset.dev, or a `brokerURL` override that is https or `http://127.0.0.1`, as the agent accepts. **A Debug build (the Dev app and every test host) never reaches another machine**: it defaults to the diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 583a6ac71..22e155313 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3107,10 +3107,7 @@ final class AppStore: ObservableObject { // Before publishing, so a remote workroom's panes find its host when they mount (#253). // No sweep while a delete is in flight: `fresh` leaves out hosts config still records, and a // delete that fails keeps them (#296). - if RemoteWorkrooms.isEnabled { - RemoteHosts.shared.adopt( - fresh, sweep: deletingProjects.isEmpty && deletingWorkrooms.isEmpty) - } + RemoteHosts.shared.adopt(fresh, sweep: deletingProjects.isEmpty && deletingWorkrooms.isEmpty) let acceptedPaths = Set(fresh.flatMap { [$0.path] + $0.workrooms.map(\.path) }) RepositoryRouter.shared.replaceLocal( registrations.filter { @@ -3199,7 +3196,7 @@ final class AppStore: ObservableObject { /// hide a failed sibling delete's hosts when that delete's own reload failed. A failed delete's /// reload does this itself, and `apply` holds the sweep again if another delete has begun. private func runHeldHostSweep() async { - guard RemoteWorkrooms.isEnabled, deletingProjects.isEmpty, deletingWorkrooms.isEmpty, + guard deletingProjects.isEmpty, deletingWorkrooms.isEmpty, RemoteHosts.shared.sweepHeld else { return } await load(warnings: "fast", surfaceErrors: false) @@ -3594,8 +3591,7 @@ final class AppStore: ObservableObject { /// Whether creating a container workroom is on for `project`: not while another create holds it /// busy, since a second create would build a second base. func canCreateRemoteWorkroom(in project: Project) -> Bool { - RemoteWorkrooms.isEnabled && !isBusyProject(project.path) - && !deletingProjects.contains(project.path) + !isBusyProject(project.path) && !deletingProjects.contains(project.path) } /// Creates a remote workroom for `project` at `place` (#253, #309, #356): a container on this Mac diff --git a/macapp/WorkroomApp/Core/DefaultsKeys.swift b/macapp/WorkroomApp/Core/DefaultsKeys.swift index a2f193da3..dd8c9813e 100644 --- a/macapp/WorkroomApp/Core/DefaultsKeys.swift +++ b/macapp/WorkroomApp/Core/DefaultsKeys.swift @@ -86,12 +86,6 @@ extension Defaults.Keys { /// OQ22 semantics: report only, and never let a box sleep on its own. static let askAtAwakeCeiling = Key("askAtAwakeCeiling", default: false, suite: .app) - /// Turns remote workrooms on in a Nightly or Dev build (`RemoteWorkrooms.isEnabled`): the - /// Remote workrooms section in Settings (the broker sign-in, #251) and creating, opening and - /// deleting them (#253). A stable build ignores it. Hidden: - /// `defaults write remoteWorkroomsPreview -bool YES`. - static let remoteWorkroomsPreview = Key( - "remoteWorkroomsPreview", default: false, suite: .app) /// Overrides the image a container workroom's host runs (#253, #309), which is otherwise the one /// this build pins (`RemoteWorkrooms.hostImage`). Hidden; set it with /// `defaults write remoteHostImage `, e.g. `workroom-host` for the image diff --git a/macapp/WorkroomApp/Core/Models.swift b/macapp/WorkroomApp/Core/Models.swift index 5ac069c35..730966690 100644 --- a/macapp/WorkroomApp/Core/Models.swift +++ b/macapp/WorkroomApp/Core/Models.swift @@ -221,12 +221,11 @@ struct TerminalTarget: Identifiable, Hashable { /// editor, run command, status) guards on `isMissing`; only the rendering sites tell the reasons /// apart. let unavailability: Unavailability? - /// The host of a remote workroom whose panes this app can reach (#253): set only while remote - /// workrooms are on (`RemoteWorkrooms.isEnabled`) and its host is recorded and serving. Panes + /// The host of a remote workroom whose panes this app can reach (#253): set only while its host + /// is recorded and serving. Panes /// mount there; every other local action still guards on `isMissing`, which stays true. var remoteHost: UUID? = nil - /// Why a remote workroom's panes don't open here, when that isn't simply this build - /// (`Workroom.remoteNote`). + /// Why a remote workroom's panes don't open here (`Workroom.remoteNote`). var remoteNote: String? = nil /// What kind of host a remote workroom is on (`HostDescriptor.kindDescription`), for its icon's /// tooltip (#309). @@ -340,7 +339,6 @@ extension Workroom { "Another Workroom build made it (\(host.provisioner ?? "unknown")), and only that build " + "can open it." } - if !RemoteWorkrooms.isEnabled { return "Remote workrooms are turned off in this build." } switch host.state { case "creating": return "It isn't ready: it is being created, or creating it was interrupted. If nothing is " @@ -356,8 +354,7 @@ extension Workroom { /// A host with no `state` is serving: "creating", "failed" and "destroyed" are not. Only this /// build's: another build's host takes its key. var reachableHost: UUID? { - guard let host, host.state == nil, host.provisioner == RemoteWorkrooms.provisioner, - RemoteWorkrooms.isEnabled + guard let host, host.state == nil, host.provisioner == RemoteWorkrooms.provisioner else { return nil } return host.id } diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index ff9dd1176..5d2444977 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -6,23 +6,6 @@ import os /// host the app provisions. A remote workroom always belongs to a project registered on this Mac /// (design doc, Phase 4: there are no remote projects). enum RemoteWorkrooms { - /// Nightly and Dev only, and there only with `Defaults[.remoteWorkroomsPreview]`, until the - /// Phase 4 success criteria pass on two real providers (design doc, Next Steps item 5). A stable - /// build shows no remote UI whatever the setting says. - static var isEnabled: Bool { - if let enabledForTesting { return enabledForTesting } - #if DEBUG - let channel = true - #else - let channel = ReleaseChannel.isNightlyBuild - #endif - return channel && Defaults[.remoteWorkroomsPreview] - } - - /// Set by tests in place of `Defaults[.remoteWorkroomsPreview]`: parallel test processes share one - /// defaults domain, and this is per process. - nonisolated(unsafe) static var enabledForTesting: Bool? - /// Which container runtime makes a workroom's host (#309): Docker (Desktop, OrbStack, Colima) or /// Apple's `container`. The raw value is the descriptor's `driver`; `container` predates Apple's /// and keeps meaning Docker, so every record made before stays Docker's. diff --git a/macapp/WorkroomApp/Core/UITestFixture.swift b/macapp/WorkroomApp/Core/UITestFixture.swift index 2a60e46df..9e6254f81 100644 --- a/macapp/WorkroomApp/Core/UITestFixture.swift +++ b/macapp/WorkroomApp/Core/UITestFixture.swift @@ -641,13 +641,6 @@ enum UITestFixture { text("WorkroomUITestDimUnfocusedPanes").map { ($0 as NSString).boolValue } ?? true } - /// Whether a fixture launch has the remote preview on (`-WorkroomUITestRemotePreview 1`, #309): - /// New Workroom becomes a submenu of places. Off unless given, so no other test sees it; mirrored - /// into `Defaults` (`applyFixtureDefaults`), since the argument domain would shadow the key. - static var remotePreview: Bool { - text("WorkroomUITestRemotePreview").map { ($0 as NSString).boolValue } ?? false - } - /// A container workroom create downloading its image, this far along (`-WorkroomUITestImagePull /// 0.42`, #309), seeded on the fixture project so the row's progress can be seen without a real /// pull. nil unless given. @@ -962,8 +955,6 @@ enum UITestFixture { // Auto-even splits: same persistence trap, and it decides where every divider in a split lands // (issue #126) — an unpinned value rebaselines any test or screenshot that measures panes. Defaults[.autoResizeSplitsEvenly] = autoResizeSplitsEvenly - // The remote preview PERSISTS too, and decides whether New Workroom is a submenu (#309). - Defaults[.remoteWorkroomsPreview] = remotePreview // Pinned "already onboarded" for the same reason as `themeFamily`/`diffViewMode` above: the flag // PERSISTS in the real Dev `Defaults` domain, so a fresh machine with zero registered projects // would otherwise pop the onboarding wizard (issue #151) over e.g. `NewWorkroomDialogUITests`' diff --git a/macapp/WorkroomApp/Views/NewWorkroomDialog.swift b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift index 2e942c192..388977564 100644 --- a/macapp/WorkroomApp/Views/NewWorkroomDialog.swift +++ b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift @@ -54,14 +54,9 @@ struct NewWorkroomDialog: View { // Capture the anchor NOW, not at landing: the create is async and the user can select a // different workroom while a setup script runs. let anchor = (split || splitIntent) ? store.selectedTargetID : nil - if RemoteWorkrooms.isEnabled { - placing = project - placeAnchor = anchor - highlighted = WorkroomPlace.all.firstIndex { isUsable($0, in: project) } ?? 0 - return - } - onClose() - Task { await store.createWorkroom(in: project, splitAnchor: anchor) } + placing = project + placeAnchor = anchor + highlighted = WorkroomPlace.all.firstIndex { isUsable($0, in: project) } ?? 0 } /// Creates `project`'s workroom at `place`, unless it can't go there now. diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index 3e271d14c..300a97176 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -288,18 +288,12 @@ struct ProjectSidebar: View { ) { store.pendingProjectSettings = PendingProjectSettings(project: project) } - Group { - // With the preview on, "+" asks where, as the context menu does (#309). - if RemoteWorkrooms.isEnabled { - CreateRowButton( - help: "New workroom in \(project.displayName), on this Mac or in a local container", - places: { newWorkroomPlaces(in: project) }) - } else { - CreateRowButton(help: "New workroom in \(project.displayName)") { - Task { await store.createWorkroom(in: project) } - } - } - } + // "+" asks where, as the context menu does (#309). + CreateRowButton( + help: + "New workroom in \(project.displayName), on this Mac, in a local container or on boxd", + places: { newWorkroomPlaces(in: project) } + ) .opacity(busy ? 0 : 1) .allowsHitTesting(!busy) .overlay { @@ -331,19 +325,14 @@ struct ProjectSidebar: View { if inside { hovered = id } else if hovered == id { hovered = nil } } .contextMenu { - // Nightly and Dev, behind the preview setting (#253, #309): New Workroom asks where, this Mac - // or a local container. Without the preview there is only this Mac, so no submenu. - if RemoteWorkrooms.isEnabled { - Menu { - newWorkroomPlaces(in: project) - } label: { - Label("New Workroom", systemImage: "plus") - } - // No tooltip here: a submenu's parent shows it on top of the submenu, over This Mac. Each - // entry has its own. - } else { - newLocalWorkroomButton(Label("New Workroom", systemImage: "plus"), in: project) + // New Workroom asks where: this Mac, a local container or boxd (#253, #309, #356). + Menu { + newWorkroomPlaces(in: project) + } label: { + Label("New Workroom", systemImage: "plus") } + // No tooltip here: a submenu's parent shows it on top of the submenu, over This Mac. Each + // entry has its own. Divider() Button { store.pendingProjectSettings = PendingProjectSettings(project: project) @@ -749,24 +738,14 @@ private struct SetupSpinner: View { } } -/// The always-visible "new workroom" button on a project row. Its own hover paints a -/// subtle neutral background to read as an actionable control. +/// The always-visible "new workroom" button on a project row: a menu of the places a workroom can +/// go (#309). Its own hover paints a subtle neutral background to read as an actionable control. private struct CreateRowButton: View { let help: String - /// What a click does, or nil when it opens `places` instead (#309). - let action: (() -> Void)? - let places: (() -> Places)? + @ViewBuilder let places: () -> Places @State private var hovering = false private let theme = ThemeService.shared - init(help: String, action: @escaping () -> Void) where Places == EmptyView { - (self.help, self.action, self.places) = (help, action, nil) - } - - init(help: String, @ViewBuilder places: @escaping () -> Places) { - (self.help, self.action, self.places) = (help, nil, places) - } - private var glyph: some View { Image(systemName: "plus") .font(.system(size: 11)) @@ -779,23 +758,16 @@ private struct CreateRowButton: View { } var body: some View { - Group { - if let places { - // A menu that looks exactly like the button it replaces: drawn by SwiftUI as a plain - // button, so the glyph keeps its size, weight and colour, with no arrow. - Menu(content: places) { glyph.accessibilityLabel(help) } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize() - } else if let action { - Button(action: action) { glyph } - .buttonStyle(.plain) - } - } - .onHover { hovering = $0 } - .help(help) - .accessibilityLabel(help) + // A menu that looks exactly like a button: drawn by SwiftUI as a plain button, so the glyph + // keeps its size, weight and colour, with no arrow. + Menu(content: places) { glyph.accessibilityLabel(help) } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .onHover { hovering = $0 } + .help(help) + .accessibilityLabel(help) } } diff --git a/macapp/WorkroomApp/Views/SettingsView.swift b/macapp/WorkroomApp/Views/SettingsView.swift index 2ab94f37e..98ce89cc8 100644 --- a/macapp/WorkroomApp/Views/SettingsView.swift +++ b/macapp/WorkroomApp/Views/SettingsView.swift @@ -179,7 +179,6 @@ private struct GeneralSettingsPane: View { @Default(.confirmOnQuit) private var confirmOnQuit @Default(.globalHotkey) private var globalHotkey @Default(.showMenuBarItem) private var showMenuBarItem - @Default(.remoteWorkroomsPreview) private var remoteWorkroomsPreview var body: some View { Form { @@ -195,15 +194,14 @@ private struct GeneralSettingsPane: View { Toggle("Show notifications in the menu bar", isOn: $showMenuBarItem) .help("Show the Workroom notifications item in the menu bar.") - if remoteWorkroomsPreview && RemoteWorkrooms.isEnabled { RemoteWorkroomsSection() } + RemoteWorkroomsSection() } .formStyle(.grouped) .scrollContentBackground(.hidden) } } -/// Signing this Mac in to Codaset, which gives remote workrooms their GitHub access (#251). Behind -/// `Defaults[.remoteWorkroomsPreview]` until remote workrooms ship. +/// Signing this Mac in to Codaset, which gives remote workrooms their GitHub access (#251). private struct RemoteWorkroomsSection: View { @ObservedObject private var broker = BrokerSession.shared diff --git a/macapp/WorkroomAppTests/AppStoreDeleteRaceTests.swift b/macapp/WorkroomAppTests/AppStoreDeleteRaceTests.swift index 1ce27575a..abde911ce 100644 --- a/macapp/WorkroomAppTests/AppStoreDeleteRaceTests.swift +++ b/macapp/WorkroomAppTests/AppStoreDeleteRaceTests.swift @@ -398,9 +398,7 @@ final class AppStoreDeleteRaceTests: XCTestCase { await store.createWorkroom(in: project([a])) XCTAssertEqual(store.errorMessage, "\(project([a]).displayName) is being deleted.") XCTAssertFalse(store.isBusyProject(projectPath), "no create was started") - RemoteWorkrooms.enabledForTesting = true XCTAssertFalse(store.canCreateRemoteWorkroom(in: project([a])), "nor a remote one") - RemoteWorkrooms.enabledForTesting = nil fake.allowDelete = true await waitUntil({ store.deletingProjects.isEmpty }, "tombstone should clear after teardown") diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index a66b5b158..87ada4a09 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -735,8 +735,6 @@ final class RemoteHostsTests: XCTestCase { /// base. @MainActor func testARemoteCreateIsOffWhileItsProjectIsBusy() { - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let store = AppStore() let project = Project(path: "/proj", vcs: "git", workrooms: []) XCTAssertTrue(store.canCreateRemoteWorkroom(in: project)) @@ -797,8 +795,6 @@ final class RemoteHostsTests: XCTestCase { /// would be a no-op. @MainActor func testReactivatingARemoteTreeListsItAgain() async throws { - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let lists = Connects() lists.fail(true) lists.hold(false) @@ -817,15 +813,13 @@ final class RemoteHostsTests: XCTestCase { } } - /// A remote workroom this app can't reach (previews off, here) reads nothing on this Mac: its + /// A remote workroom this app can't reach (another build's, here) reads nothing on this Mac: its /// path names a directory on its host, not here. @MainActor func testAnUnreachableRemoteWorkroomReadsNothingHere() { - RemoteWorkrooms.enabledForTesting = false - defer { RemoteWorkrooms.enabledForTesting = nil } let target = Workroom( name: "r", path: NSTemporaryDirectory(), vcsName: "workroom/r", warnings: [], - host: HostDescriptor(provisioner: RemoteWorkrooms.provisioner, id: UUID()) + host: HostDescriptor(provisioner: "another.build", id: UUID()) ).target(inProject: "/proj") XCTAssertNil(target.remoteHost) diff --git a/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift b/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift index fcda93272..8145437e2 100644 --- a/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift +++ b/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift @@ -601,9 +601,6 @@ final class RemoteProvisioningIntegrationTests: XCTestCase { let edit = try await onHost( driver, instance.host, "cd \(path) && echo edited >> '\(tracked)' && echo new > fresh.txt") XCTAssertEqual(edit.status, 0, edit.output) - - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let mine = RemoteWorkrooms.provisioner let project = Project( path: "/proj", vcs: "git", diff --git a/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift b/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift index 364785b87..92d6ff6e1 100644 --- a/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift +++ b/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift @@ -8,13 +8,7 @@ import XCTest final class RemoteWorkroomRoutingTests: XCTestCase { private let path = "/home/workroom/r" - override func setUp() { - super.setUp() - RemoteWorkrooms.enabledForTesting = true - } - override func tearDown() { - RemoteWorkrooms.enabledForTesting = nil RepositoryRouter.shared.replaceRemote([]) super.tearDown() } diff --git a/macapp/WorkroomAppTests/SessionRestoreTests.swift b/macapp/WorkroomAppTests/SessionRestoreTests.swift index 9856f5257..08a86605e 100644 --- a/macapp/WorkroomAppTests/SessionRestoreTests.swift +++ b/macapp/WorkroomAppTests/SessionRestoreTests.swift @@ -473,21 +473,22 @@ final class SessionRestoreTests: XCTestCase { /// save. Once the workroom is reachable, its first pane restores the whole session rather than /// opening a fresh shell. func testAnUnreachableRemoteWorkroomsSessionWaitsUntilItIsReachable() throws { - RemoteWorkrooms.enabledForTesting = false - defer { RemoteWorkrooms.enabledForTesting = nil } let store = AppStore() store.terminals.makeView = { _, cwd, _ in GhosttySurfaceView(workingDirectory: cwd, spawnsSurface: false) } - store.projects = [ + func project(_ state: String?) -> Project { Project( path: "/proj", vcs: "git", workrooms: [ Workroom( name: "r", path: "/home/workroom/r", vcsName: "workroom/r", warnings: [], - host: HostDescriptor(provisioner: RemoteWorkrooms.provisioner, id: UUID())) + host: HostDescriptor(state: state, provisioner: RemoteWorkrooms.provisioner, id: UUID()) + ) ]) - ] + } + // Still being created, so not reachable yet. + store.projects = [project("creating")] let id = TerminalTarget.workroomID(project: "/proj", name: "r") let session = TargetSession( targetID: id, @@ -506,7 +507,7 @@ final class SessionRestoreTests: XCTestCase { XCTAssertEqual(store.captureWindowSession().expandedTargets, [id], "its expansion was lost") store.selectedTargetID = .workroom(project: "/proj", name: "r") - RemoteWorkrooms.enabledForTesting = true + store.projects = [project(nil)] let target = try XCTUnwrap(store.terminalTarget(forID: id)) store.ensureInitialTerminal(for: target) XCTAssertEqual(store.terminals.tabCount(forTargetID: id), 2, "the session was not restored") @@ -521,8 +522,6 @@ final class SessionRestoreTests: XCTestCase { /// restoring its own at once or opening a fresh shell; a host that cannot answer in time gets this /// Mac's own copy, whole. A second window showing the same workroom does not ask (D10). func testARemoteWorkroomWaitsForItsHostsLayoutThenFallsBackToItsOwn() async throws { - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroom = Workroom( name: "h", path: "/home/workroom/h", vcsName: "workroom/h", warnings: [], host: HostDescriptor( @@ -598,8 +597,6 @@ final class SessionRestoreTests: XCTestCase { /// has answered, nothing unchanged, an empty layout only over one the host had (D11), and a /// write that cannot reach the host leaves this Mac's copy stale, to win at the next open. func testWhatAWindowWritesToItsHosts() async throws { - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroomID = UUID() let store = AppStore() store.terminals.makeView = { _, cwd, _ in @@ -683,8 +680,6 @@ final class SessionRestoreTests: XCTestCase { /// knows, so it is not written back until something changes, although the restore re-minted /// every tab's key. func testAnUnansweredWorkroomsUnchangedCopyIsNotWrittenBack() async throws { - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroom = Workroom( name: "u", path: "/home/workroom/u", vcsName: "workroom/u", warnings: [], host: HostDescriptor( @@ -727,8 +722,6 @@ final class SessionRestoreTests: XCTestCase { return 10 } } - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroom = Workroom( name: "s", path: "/home/workroom/s", vcsName: "workroom/s", warnings: [], host: HostDescriptor( @@ -780,8 +773,6 @@ final class SessionRestoreTests: XCTestCase { let saved = AppStore.hostLayoutStore AppStore.hostLayoutStore = { _ in host } defer { AppStore.hostLayoutStore = saved } - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroomID = UUID() let store = AppStore() store.terminals.makeView = { _, cwd, _ in @@ -855,8 +846,6 @@ final class SessionRestoreTests: XCTestCase { return store } defer { AppStore.hostLayoutStore = saved } - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroomID = UUID() func project(host: UUID) -> Project { Project( @@ -918,8 +907,6 @@ final class SessionRestoreTests: XCTestCase { let saved = AppStore.hostLayoutStore AppStore.hostLayoutStore = { _ in host } defer { AppStore.hostLayoutStore = saved } - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroomID = UUID() let first = UUID() func project(host: UUID) -> Project { @@ -972,8 +959,6 @@ final class SessionRestoreTests: XCTestCase { let saved = AppStore.hostLayoutStore AppStore.hostLayoutStore = { _ in host } defer { AppStore.hostLayoutStore = saved } - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroomID = UUID() let first = UUID() func project(host: UUID) -> Project { @@ -1032,8 +1017,6 @@ final class SessionRestoreTests: XCTestCase { AppStore.hostLayoutStore = savedStore AppStore.hostSessions = savedSessions } - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroom = Workroom( name: "l", path: "/home/workroom/l", vcsName: "workroom/l", warnings: [], host: HostDescriptor( @@ -1074,8 +1057,6 @@ final class SessionRestoreTests: XCTestCase { return 2 } } - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroomID = UUID() let host = SeededHost(seed: "the seed this Mac wrote") let saved = AppStore.hostLayoutStore @@ -1127,8 +1108,6 @@ final class SessionRestoreTests: XCTestCase { let saved = AppStore.hostLayoutStore AppStore.hostLayoutStore = { _ in host } defer { AppStore.hostLayoutStore = saved } - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let workroomID = UUID() let store = AppStore() store.terminals.makeView = { _, cwd, _ in @@ -1200,8 +1179,6 @@ final class SessionRestoreTests: XCTestCase { /// directory, not a remote workroom (reachable or not), not one that no longer exists. func testOnlyALocalTargetThatOpensReattaches() { // On, so the remote workroom is one whose panes do open, on its host. - RemoteWorkrooms.enabledForTesting = true - defer { RemoteWorkrooms.enabledForTesting = nil } let store = AppStore() let here = FileManager.default.temporaryDirectory.path store.projects = [ diff --git a/macapp/WorkroomAppTests/TerminalTargetTests.swift b/macapp/WorkroomAppTests/TerminalTargetTests.swift index a3deef035..b5028b6ba 100644 --- a/macapp/WorkroomAppTests/TerminalTargetTests.swift +++ b/macapp/WorkroomAppTests/TerminalTargetTests.swift @@ -94,17 +94,14 @@ final class TerminalTargetTests: XCTestCase { /// A remote workroom is unavailable on this Mac (every local action guards on `isMissing`) but /// never shown as a missing directory; a destroyed host is its own state. - /// A serving remote workroom opens panes only while remote workrooms are on (#253), and stays - /// `isMissing` for every other local action; one being created, failed or destroyed opens none. - func testARemoteWorkroomOpensPanesOnlyWhenServingAndEnabled() { - defer { RemoteWorkrooms.enabledForTesting = nil } + /// A serving remote workroom opens panes (#253), and stays `isMissing` for every other local + /// action; one being created, failed or destroyed opens none. + func testARemoteWorkroomOpensPanesOnlyWhenServing() { let id = UUID() func target(_ host: HostDescriptor) -> TerminalTarget { Workroom(name: "x", path: "/home/w", vcsName: "workroom/x", warnings: [], host: host) .target(inProject: "/proj") } - - RemoteWorkrooms.enabledForTesting = true let mine = RemoteWorkrooms.provisioner let serving = target(HostDescriptor(provisioner: mine, id: id)) XCTAssertEqual(serving.remoteHost, id) @@ -118,9 +115,6 @@ final class TerminalTargetTests: XCTestCase { XCTAssertFalse( target(HostDescriptor(provisioner: "another.build", id: id)).opensTerminals, "another build's host takes another key") - - RemoteWorkrooms.enabledForTesting = false - XCTAssertFalse(target(HostDescriptor(provisioner: mine, id: id)).opensTerminals) } func testUnavailabilityReasons() { @@ -144,8 +138,6 @@ final class TerminalTargetTests: XCTestCase { /// A remote workroom this build could reach says why its panes don't open, rather than that this /// build cannot open remote workrooms (#253). func testAnUnopenedRemoteWorkroomSaysWhy() { - defer { RemoteWorkrooms.enabledForTesting = nil } - RemoteWorkrooms.enabledForTesting = true let mine = RemoteWorkrooms.provisioner func detail(_ host: HostDescriptor) -> String { let target = Workroom( @@ -166,8 +158,5 @@ final class TerminalTargetTests: XCTestCase { name: "x", path: "/home/w", vcsName: "workroom/x", warnings: [], host: HostDescriptor(provisioner: mine, id: UUID()) ).remoteNote, "a serving workroom opens") - RemoteWorkrooms.enabledForTesting = false - XCTAssertTrue( - detail(HostDescriptor(provisioner: mine, id: UUID())).contains("turned off in this build")) } } diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift index 29e4b4c57..618bcc1dc 100644 --- a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -1,8 +1,8 @@ import XCTest -/// New Workroom asks where once the remote preview is on (#309): This Mac, then Docker and Apple -/// Container under "Local containers" and boxd under "Remote" (#356), each disabled with its reason -/// when it can't be used here. Without the preview it stays one plain item. +/// New Workroom asks where (#309): This Mac, then Docker and Apple Container under "Local +/// containers" and boxd under "Remote" (#356), each disabled with its reason when it can't be used +/// here. // Value: protects=boxd is offered in New Workroom, disabled exactly when its title gives a reason; // fails_when=WorkroomPlace.all or the sidebar's Remote section drops boxd; why_new=the places // loops listed only Docker and Apple Container; seam=none @@ -12,10 +12,10 @@ final class NewWorkroomPlacesUITests: XCTestCase { continueAfterFailure = false } - private func launch(preview: Bool) -> XCUIApplication { + private func launch() -> XCUIApplication { let app = XCUIApplication() app.launchArguments += [ - "-WorkroomUITestFixture", "1", "-WorkroomUITestRemotePreview", preview ? "1" : "0", + "-WorkroomUITestFixture", "1", "-ApplePersistenceIgnoreState", "YES", ] app.launch() @@ -26,12 +26,12 @@ final class NewWorkroomPlacesUITests: XCTestCase { return app } - /// One launch (preview on), three surfaces in order, each asking where with the same places: + /// One launch, three surfaces in order, each asking where with the same places: /// the project's context menu, the row's "+" and File ▸ New Workroom… (⌘N, and the tab bar's +), /// where picking a project offers the same places rather than creating on this Mac. Nothing is /// picked, so nothing is created. The picker goes last: it leaves a sheet open. - func testNewWorkroomAsksWhereFromTheMenuThePlusAndThePickerWithThePreviewOn() { - let app = launch(preview: true) + func testNewWorkroomAsksWhereFromTheMenuThePlusAndThePicker() { + let app = launch() let newWorkroom = app.menuItems["New Workroom"] XCTAssertTrue(newWorkroom.waitForExistence(timeout: 5)) newWorkroom.hover() @@ -108,8 +108,8 @@ final class NewWorkroomPlacesUITests: XCTestCase { func testADownloadingImageShowsItsProgressAndTurnsThePlacesOff() { let app = XCUIApplication() app.launchArguments += [ - "-WorkroomUITestFixture", "1", "-WorkroomUITestRemotePreview", "1", - "-WorkroomUITestImagePull", "0.42", "-ApplePersistenceIgnoreState", "YES", + "-WorkroomUITestFixture", "1", "-WorkroomUITestImagePull", "0.42", + "-ApplePersistenceIgnoreState", "YES", ] app.launch() XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) @@ -189,21 +189,4 @@ final class NewWorkroomPlacesUITests: XCTestCase { XCTAssertTrue(project.waitForExistence(timeout: 5), "the picker didn't list the project") project.click() } - - func testNewWorkroomIsOneItemWithThePreviewOff() { - let app = launch(preview: false) - defer { - // The row's "+" as it looks without the preview, to compare with the places one. - app.typeKey(.escape, modifierFlags: []) - app.otherElements["sidebar.project.UITestProject"].hover() - let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) - shot.name = "plus-plain" - shot.lifetime = .keepAlways - add(shot) - } - XCTAssertTrue(app.menuItems["New Workroom"].waitForExistence(timeout: 5)) - XCTAssertFalse(app.menuItems["This Mac"].exists, "a submenu appeared without the preview") - XCTAssertFalse(app.menuItems["New Remote Workroom"].exists) - app.typeKey(.escape, modifierFlags: []) - } } From f19acff2a12dcbe33ad6e2610639a959513d4fea Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Wed, 7 Oct 2026 23:11:25 +0100 Subject: [PATCH 2/4] feat: stable releases pin a host image and hand remote agents off With remote workrooms in every build, a stable release needs what a Nightly already has: - release.yml runs the host-image workflow under the release's tag and pins its digest into the DMG; release.sh refuses a Release build without one, as it already did a Nightly. Without it a stable app pulled the bare `workroom-host`, which Docker Hub refuses, so no container workroom could be created. - AgentBootstrap hands a remote host's agent off to the bundled one in every build. It followed the local hand-off's Nightly-only gate, so a stable host kept its first agent until the box restarted (the supervisor never lets it idle out) and quietly lost each newer build's services. The local hand-off stays gated. - Comments and design docs that still said remote workrooms were Nightly-only, or behind the preview, now say they are not. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/host-image.yml | 4 ++-- .github/workflows/release.yml | 18 +++++++++++++++--- docs/designs/oq8-cross-machine-reattach.md | 4 ++-- docs/designs/remote-workrooms.md | 7 +++++-- macapp/Scripts/release.sh | 10 +++++----- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 4 ++-- .../Core/Session/AgentBootstrap.swift | 7 +++++-- .../Core/Session/AgentHandOff.swift | 2 +- .../WorkroomApp/Views/NewWorkroomDialog.swift | 9 ++++----- 9 files changed, 41 insertions(+), 24 deletions(-) diff --git a/.github/workflows/host-image.yml b/.github/workflows/host-image.yml index ae26ac141..df455d742 100644 --- a/.github/workflows/host-image.yml +++ b/.github/workflows/host-image.yml @@ -1,7 +1,7 @@ name: Host image -# Publishes the `workroom-host` image a local container workroom runs (#309), for the Nightly -# workflow, which pins the app it builds to the digest this outputs. Release joins it with #260. +# Publishes the `workroom-host` image a local container workroom runs (#309), for the Nightly and +# Release workflows, which pin the app they build to the digest this outputs. # # Each architecture is built once, smoke-tested as that exact image (host-image-test.sh with # WR_HOST_IMAGE), and only then pushed by digest: a second build for the push could resolve other diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5615a0b15..c5311fa14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,17 @@ concurrency: cancel-in-progress: false jobs: + # The `workroom-host` image this release's local container workrooms run (#309), built, tested + # and published per architecture under the release's tag; its digest is pinned into the app. + # Remote workrooms are in every build, so a stable release needs one as a Nightly does. + host-image: + uses: ./.github/workflows/host-image.yml + permissions: + contents: read + packages: write + with: + tag: ${{ github.ref_name }} + release: runs-on: ubuntu-latest steps: @@ -41,10 +52,9 @@ jobs: # NOTARY_KEY / NOTARY_KEY_ID / NOTARY_ISSUER_ID. # Optional: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT enable Sentry dSYM upload so release # crashes symbolicate; release.sh skips that step when any of them (or sentry-cli) is absent. - # No `workroom-host` image (#309) yet: container workrooms are Nightly-only until #260, so a - # release has nothing to pin, and an image build that failed on tag day would hold the DMG back - # after the CLI has shipped. Add the `host-image` job (as nightly.yml has it) with #260. + # The release's DMG pins the `workroom-host` image its container workrooms run (#309), by digest. macapp: + needs: host-image runs-on: macos-15 steps: - uses: actions/checkout@v5 @@ -132,6 +142,8 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + # The host image this app runs, by digest (release.sh → WorkroomHostImage). + WORKROOM_HOST_IMAGE: ${{ needs.host-image.outputs.image }} run: | echo "$NOTARY_KEY" | openssl base64 -d -A > "$RUNNER_TEMP/notary.p8" NOTARY_KEY_PATH="$RUNNER_TEMP/notary.p8" make app-test APP_SIGN_FLAGS="CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=YES DEVELOPMENT_TEAM=" diff --git a/docs/designs/oq8-cross-machine-reattach.md b/docs/designs/oq8-cross-machine-reattach.md index ee2e2673d..b1a0c06b1 100644 --- a/docs/designs/oq8-cross-machine-reattach.md +++ b/docs/designs/oq8-cross-machine-reattach.md @@ -171,8 +171,8 @@ Codex (gpt-6.1-sol, cold read of the repo): - **Capability detection:** the layout requests arrive with a protocol version bump; the app reads the agent's version at the handshake it already performs. An older agent means today's per-Mac behaviour for that workroom: no host layout, no second-Mac restore, nothing shown to the user - (the remote UI is Nightly-only, and the bootstrap pushes the new agent on the next connect, - #231). + (the bootstrap pushes the new agent on the next connect and hands off to it, #231, in every + build since 2026-10-07). - **Lifecycle:** a remote workroom's layout lives on that workroom's own host, so deleting the workroom destroys the host and its layout with it. No delete request is needed in #255. - **Session metadata:** the workroom ID, initial title and creation order travel in the attach diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index 0c27fb143..2b22ebe5f 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -26,7 +26,7 @@ the agent, not a shim. Follow-ups from #253's reviews are #283 to #288; #283 (cl ends its session on the host) is merged (PR #306, 2026-10-03), with its own follow-ups in #293, #297 and #304. The rest of Phase 4 is open: the second real provider, exe.dev (#259), and boxd live fork (#258), which is deferred to a later release and does not gate #260. #260 is the gate: it runs the -Success Criteria on two real providers, and the remote UI leaves Nightly only after they pass. The +Success Criteria on two real providers; the remote UI already left Nightly, ahead of it (2026-10-07). The first Nightly DMG with the Linux agent inside (#227) still has to be checked, and that check is part of #260. Each merged item has its "As built" entry under Phase 4, except #255, which is recorded as the answer to open question 8. @@ -3348,7 +3348,10 @@ disagreement passes every test on either side alone while presenting as an empty reattach in `wr-agent attach` (the same ssh and pty, ~100 ms, local too) is the upgrade, and needs the stdin and resize relays re-plumbed onto a swappable stream. - *Gated as the local hand-off is* (`AgentHandOff.isEnabled`, Nightly and Dev): one - older-agent policy on both hosts. Off, the scripts skip the hand-off and the install still + older-agent policy on both hosts. **Changed 2026-10-07:** remote hand-off is on in every + build, stable included, when remote workrooms left Nightly; the local one stays gated. A + remote agent runs under a supervisor with no idle exit, so without it a stable host would + keep its first agent until the box restarts. Off, the scripts skip the hand-off and the install still renames, so the next probe sees a match, nothing is pushed again, and the supervisor picks the file up on its next start. - *The crashed restore, remotely: measured.* A binary that passes `protocol` and the restore diff --git a/macapp/Scripts/release.sh b/macapp/Scripts/release.sh index 31d8c79b7..bf14bac3a 100755 --- a/macapp/Scripts/release.sh +++ b/macapp/Scripts/release.sh @@ -97,12 +97,12 @@ BUILD_NUMBER="$(git -C "$MACAPP_DIR/.." rev-list --count HEAD 2>/dev/null || ech # rejects. Archiving also produces a distribution build with get-task-allow omitted. # The `workroom-host` image the app's local container workrooms run, by digest (#309): CI's # host-image workflow publishes it and passes it here. Without it the app falls back to a locally -# built `workroom-host` (`make remote-host-image`), which a user won't have, so a Nightly, the only -# build that runs container workrooms until #260, refuses to build without one. +# built `workroom-host` (`make remote-host-image`), which a user won't have, so a Release or a +# Nightly, both of which run container workrooms, refuses to build without one. case "$CONFIGURATION:${WORKROOM_HOST_IMAGE:-}" in - Nightly:*@sha256:*) ;; - Nightly:*) - echo "error: a Nightly needs WORKROOM_HOST_IMAGE, the workroom-host image by digest" \ + Release:*@sha256:* | Nightly:*@sha256:*) ;; + Release:* | Nightly:*) + echo "error: a $CONFIGURATION build needs WORKROOM_HOST_IMAGE, the workroom-host image by digest" \ "(@sha256:...), got '${WORKROOM_HOST_IMAGE:-}'." >&2 exit 1 ;; esac diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index 5d2444977..3cbc8db37 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -946,8 +946,8 @@ final class RemoteHosts: @unchecked Sendable { for host in hosts where RemoteWorkrooms.isLive(host) { let key = try Self.deletionKey(host) if environments[key] == nil { environments[key] = try environment(key).1 } - // Taking a box down needs it on its driver, whatever a reload adopted: with previews off it - // adopted nothing, and the box would read as unknown. A boxd driver needs nothing adopted. + // Taking a box down needs it on its driver, whatever a reload adopted: one that has not + // adopted it yet would read the box as unknown. A boxd driver needs nothing adopted. guard key.runtime != nil else { continue } let driver = try containerDriver(key) guard let id = host.id, let record = host.container, driver.record(of: .remote(id)) == nil diff --git a/macapp/WorkroomApp/Core/Session/AgentBootstrap.swift b/macapp/WorkroomApp/Core/Session/AgentBootstrap.swift index 8ed3f848c..574b0080f 100644 --- a/macapp/WorkroomApp/Core/Session/AgentBootstrap.swift +++ b/macapp/WorkroomApp/Core/Session/AgentBootstrap.swift @@ -124,7 +124,7 @@ enum AgentBootstrap { static func connect( host: HostID, driver: any HostDriver, socket: String, agent: (String) -> URL? = PersistentSessionPaths.linuxAgentURL(architecture:), - handOff: Bool = AgentHandOff.isEnabled, resources: URL? = GhosttyResources.bundledURL, + handOff: Bool = true, resources: URL? = GhosttyResources.bundledURL, watched: Bool = true ) async throws -> AgentVCSConnection { let outcome = try await ensure( @@ -166,10 +166,13 @@ enum AgentBootstrap { /// /// `agent` finds the bundled binary for an architecture (the bundle's, or a test's), and /// `resources` is the bundled Ghostty tree whose terminfo and shell integration go beside it. + /// `handOff` is on in every build, unlike the local hand-off (`AgentHandOff.isEnabled`): off, a + /// remote host keeps its first agent until the box restarts, since its supervisor never lets it + /// idle out, and so never gets a newer build's services. static func ensure( host: HostID, driver: any HostDriver, socket: String, agent: (String) -> URL? = PersistentSessionPaths.linuxAgentURL(architecture:), - handOff: Bool = AgentHandOff.isEnabled, resources: URL? = GhosttyResources.bundledURL + handOff: Bool = true, resources: URL? = GhosttyResources.bundledURL ) async throws -> Outcome { let binary = binary(besideSocket: socket) let urls = architectures.compactMap { architecture in diff --git a/macapp/WorkroomApp/Core/Session/AgentHandOff.swift b/macapp/WorkroomApp/Core/Session/AgentHandOff.swift index 98f388f59..131868112 100644 --- a/macapp/WorkroomApp/Core/Session/AgentHandOff.swift +++ b/macapp/WorkroomApp/Core/Session/AgentHandOff.swift @@ -19,7 +19,7 @@ import os /// when the exec lands still loses its connection and ends as if its shell had, with the session /// carrying on detached: a launch-only race, since panes attach after this is asked for. A REMOTE /// pane's attach exits 255 instead, and the app attaches it again (#231; `AgentBootstrap` is the -/// remote side of this policy). +/// remote side of this, on in every build). enum AgentHandOff { /// Nightly and Dev only for now. A hand-off bug kills local terminals on an update, which has /// never been possible before, so stable waits until Nightly has proven it. diff --git a/macapp/WorkroomApp/Views/NewWorkroomDialog.swift b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift index 388977564..17c3bca3a 100644 --- a/macapp/WorkroomApp/Views/NewWorkroomDialog.swift +++ b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift @@ -2,7 +2,7 @@ import SwiftUI /// New Workroom picker (issue #81). A searchable list of projects, raised by File ▸ New Workroom /// (⌘N). Type to filter (partial, case-insensitive); ↑/↓ move the highlight; ⏎ or a click picks a -/// project and **immediately** creates + opens a new workroom in it via `AppStore.createWorkroom`. +/// project, and the dialog then asks where its new workroom goes (see below). /// /// Structurally this is `ThemePicker` (search field + scroll/highlight + `.onKeyPress`), with one /// deliberate difference: ↑/↓ only MOVE the highlight here — they never create. Creating a workroom @@ -12,9 +12,8 @@ import SwiftUI /// replacing it (issue #163); raised by ⌥⌘N the whole dialog is already in split mode, so a plain /// ⏎ splits — which is why the title and footer are derived from `PickerSplitIntent`, not fixed. /// -/// With the remote preview on (#309), picking a project doesn't create yet: the dialog then asks -/// where, This Mac or a local container, as the sidebar's New Workroom menu does, and the place -/// picked creates. +/// Picking a project doesn't create yet (#309): the dialog asks where, This Mac, a local container +/// or boxd, as the sidebar's New Workroom menu does, and the place picked creates and opens it. /// /// ┌─ "New Workroom [(split right)]" ─ Done ─┐ /// │ 🔍 [ filter… ] │ ← auto-focused; single-line, so ↑/↓/⏎ bubble up @@ -38,7 +37,7 @@ struct NewWorkroomDialog: View { /// Index into `filtered` of the keyboard-highlighted row (↑/↓ move it, ⏎ / click pick it). @State private var highlighted = 0 @FocusState private var searchFocused: Bool - /// The project picked while the remote preview is on (#309), whose workroom's place is asked next. + /// The project picked (#309), whose workroom's place is asked next. @State private var placing: Project? /// Where that workroom lands beside, taken when its project was picked. @State private var placeAnchor: SidebarID? From 8f8c51c8cc1999e7864b4c10cccff05b7326fea3 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Thu, 8 Oct 2026 10:36:51 +0100 Subject: [PATCH 3/4] fix(macapp): a remote create reads the repository from origin when gh can't say Pre-landing review fixes for ungating remote workrooms. Codaset sign-in alone makes a remote place available, but every create first asked `gh repo view` which GitHub repository the project is, and that needs gh installed and signed in. A stable user signed in to Codaset without gh saw the place enabled, and every create failed with "Couldn't find its GitHub repository with gh." Now, when gh can't answer, the create reads the project's origin remote with git alone (`WorkroomStatusResolver.originRepository`). `GitHubRepository(remote:)` parses the https, scp-style (`git@host:owner/name`) and `ssh://` forms. gh still answers first, since it knows which remote is GitHub's. A non-GitHub origin still fails with its host named. Also corrects comments the ungating left false: TerminalTargetTests, SessionRestoreTests, `Workroom.remoteNote`, the `remote-host-image` make target and ci.yml's host-image step. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +-- Makefile | 2 +- macapp/WorkroomApp/Core/AppStore.swift | 15 ++++++++-- .../WorkroomApp/Core/GitHubRepository.swift | 29 +++++++++++++++++++ macapp/WorkroomApp/Core/Models.swift | 4 +-- .../Core/WorkroomStatusResolver.swift | 9 ++++++ .../GitHubRepositoryTests.swift | 29 +++++++++++++++++++ .../SessionRestoreTests.swift | 1 - .../TerminalTargetTests.swift | 3 +- .../WorkroomStatusResolverTests.swift | 17 +++++++++++ 10 files changed, 102 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42ead0c62..0767e983f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,8 +326,8 @@ jobs: --cache-to type=gha,mode=max,scope=ssh-fixture run: vcs/scripts/ssh-fixture/run.sh "vcs/target/$TARGET/release/wr-agent" - # The `workroom-host` image (`make remote-host-image`, FIXTURE=0) a Nightly app's remote - # workrooms run on (#253). The step above only builds FIXTURE=1, so this is the only check on + # The `workroom-host` image (`make remote-host-image`, FIXTURE=0) an app's remote workrooms + # run on (#253). The step above only builds FIXTURE=1, so this is the only check on # the variant's own paths (#288). It builds twice: a FIXTURE=1 control, whose apt layer is the # one the step above caches under `ssh-fixture`, so it only reads that scope; then the host, # under its own scope. Never one scope for both: a second export replaces the first. diff --git a/Makefile b/Makefile index 383bb033b..0a7f7e85d 100644 --- a/Makefile +++ b/Makefile @@ -139,7 +139,7 @@ app-uitest: ## Run the app's UI tests (XCUITest — needs the GUI session; queue app-identity: ## Print the bundle id this checkout's Debug build gets (one per workroom) @echo "com.developwithstyle.workroom.dev$(APP_DEV_ID_SUFFIX)" -remote-host-image: ## Build the `workroom-host` image a Nightly or Dev app's remote workrooms run on (Docker, #253) +remote-host-image: ## Build the `workroom-host` image an app's remote workrooms run on (Docker, #253) docker build --tag workroom-host vcs/scripts/ssh-fixture remote-host-image-test: ## Build the `workroom-host` image, without its tag, and smoke-test it: sshd serves and is hardened, no fixture pieces, a pushed agent starts (#288) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 22e155313..1718ce0dc 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3619,9 +3619,18 @@ final class AppStore: ObservableObject { if key.isBoxd, environment.client == nil { throw RemoteWorkrooms.Failure.codasetRequired } - let resolution = await WorkroomStatusResolver().resolveRepository(in: project.path) - guard case .found(let repository) = resolution else { - throw RemoteWorkrooms.Failure.notOnGitHub("Couldn't find its GitHub repository with gh.") + // gh first, as it knows which remote is GitHub's; without it (not installed, or signed out + // while Codaset is signed in, which is enough for a remote workroom) origin says (#260). + let resolver = WorkroomStatusResolver() + var repository: GitHubRepository? + if case .found(let found) = await resolver.resolveRepository(in: project.path) { + repository = found + } else { + repository = await resolver.originRepository(in: project.path) + } + guard let repository else { + throw RemoteWorkrooms.Failure.notOnGitHub( + "Its origin remote names no GitHub repository, and gh couldn't say which one it is.") } guard repository.host == "github.com" else { throw RemoteWorkrooms.Failure.notOnGitHub("Its origin is on \(repository.host).") diff --git a/macapp/WorkroomApp/Core/GitHubRepository.swift b/macapp/WorkroomApp/Core/GitHubRepository.swift index afb632cc0..d7e3448f9 100644 --- a/macapp/WorkroomApp/Core/GitHubRepository.swift +++ b/macapp/WorkroomApp/Core/GitHubRepository.swift @@ -47,6 +47,35 @@ struct GitHubRepository: Hashable, Sendable { self.init(host: host, owner: segments[0], name: segments[1]) } + /// Parses a git remote, as `git remote get-url` prints it (#260): the https form `init?(url:)` + /// takes, scp-style `[user@]host:owner/name`, or `ssh://[user@]host/owner/name`, `.git` or not. + /// For a create when `gh` can't say which repository a project is. Anything else is nil. + init?(remote string: String) { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if let https = GitHubRepository(url: trimmed) { + self = https + return + } + let host: String + let path: Substring + if trimmed.hasPrefix("ssh://") { + guard let url = URL(string: trimmed), let found = url.host, url.port == nil else { + return nil + } + (host, path) = (found, Substring(url.path)) + } else { + // scp-style: everything before the first `:` is `[user@]host`, and the path is relative. + guard !trimmed.contains("://"), let colon = trimmed.firstIndex(of: ":") else { return nil } + let authority = trimmed[.. GitHubRepository? { + let r = await runner.run("git", ["remote", "get-url", "origin"], in: dir, timeout: timeout) + guard r.exitCode == 0, !r.timedOut else { return nil } + return GitHubRepository(remote: r.stdout) + } + /// CI for `commit`, as GitHub's own combined **status check rollup** for that commit — the same /// aggregate the GitHub UI shows, covering *all* check types (Actions check-runs + external commit /// statuses + check-run apps), not just Actions runs (#76). diff --git a/macapp/WorkroomAppTests/GitHubRepositoryTests.swift b/macapp/WorkroomAppTests/GitHubRepositoryTests.swift index cc1eb7afc..cf95b2f21 100644 --- a/macapp/WorkroomAppTests/GitHubRepositoryTests.swift +++ b/macapp/WorkroomAppTests/GitHubRepositoryTests.swift @@ -75,6 +75,35 @@ final class GitHubRepositoryTests: XCTestCase { XCTAssertEqual(repo, GitHubRepository(host: "github.com", owner: "o", name: "r")) } + // MARK: - Remote parsing (`git remote get-url origin`, for a create without gh) + + /// A project's origin read from git alone (#260): https, scp-style and ssh:// remotes all name + /// the same repository, `.git` or not. + func testParsesEveryRemoteFormGitUses() throws { + let expected = try XCTUnwrap(GitHubRepository(host: "github.com", owner: "octo", name: "repo")) + for remote in [ + "https://github.com/octo/repo.git\n", + "git@github.com:octo/repo.git", + "git@github.com:octo/repo", + "github.com:octo/repo.git", + "ssh://git@github.com/octo/repo.git", + "ssh://github.com/octo/repo", + ] { + XCTAssertEqual(GitHubRepository(remote: remote), expected, remote) + } + } + + /// A remote that names no single repository is nil rather than a guess. + func testRefusesARemoteThatNamesNoRepository() { + for remote in [ + "", "/Users/me/repos/repo.git", "file:///repos/repo.git", "git@github.com:octo", + "git@github.com:octo/repo/extra", "ssh://git@github.com:2222/octo/repo.git", + "git@github.com:/octo/repo.git", + ] { + XCTAssertNil(GitHubRepository(remote: remote), remote) + } + } + // MARK: - URL parsing (`gh repo view --json url`) func testParsesTheURLGhPrints() throws { diff --git a/macapp/WorkroomAppTests/SessionRestoreTests.swift b/macapp/WorkroomAppTests/SessionRestoreTests.swift index 08a86605e..761a6fc02 100644 --- a/macapp/WorkroomAppTests/SessionRestoreTests.swift +++ b/macapp/WorkroomAppTests/SessionRestoreTests.swift @@ -1178,7 +1178,6 @@ final class SessionRestoreTests: XCTestCase { /// The app lets a target's panes reattach only while it is local and opens here: not a missing /// directory, not a remote workroom (reachable or not), not one that no longer exists. func testOnlyALocalTargetThatOpensReattaches() { - // On, so the remote workroom is one whose panes do open, on its host. let store = AppStore() let here = FileManager.default.temporaryDirectory.path store.projects = [ diff --git a/macapp/WorkroomAppTests/TerminalTargetTests.swift b/macapp/WorkroomAppTests/TerminalTargetTests.swift index b5028b6ba..7c77a0544 100644 --- a/macapp/WorkroomAppTests/TerminalTargetTests.swift +++ b/macapp/WorkroomAppTests/TerminalTargetTests.swift @@ -135,8 +135,7 @@ final class TerminalTargetTests: XCTestCase { XCTAssertTrue(target([destroyed], HostDescriptor(state: "destroyed")).isMissing) } - /// A remote workroom this build could reach says why its panes don't open, rather than that this - /// build cannot open remote workrooms (#253). + /// A remote workroom whose panes don't open says why (#253). func testAnUnopenedRemoteWorkroomSaysWhy() { let mine = RemoteWorkrooms.provisioner func detail(_ host: HostDescriptor) -> String { diff --git a/macapp/WorkroomAppTests/WorkroomStatusResolverTests.swift b/macapp/WorkroomAppTests/WorkroomStatusResolverTests.swift index c3b750644..7eb2db2dd 100644 --- a/macapp/WorkroomAppTests/WorkroomStatusResolverTests.swift +++ b/macapp/WorkroomAppTests/WorkroomStatusResolverTests.swift @@ -265,6 +265,23 @@ final class WorkroomStatusResolverTests: XCTestCase { XCTAssertEqual(runner.calls.first?.args, ["repo", "view", "--json", "url", "-q", ".url"]) } + /// A create without gh (signed in to Codaset only) reads the repository from the project's own + /// origin remote (#260); a project with no origin, or git failing, has none. + func testOriginRepositoryReadsGitAlone() async { + let runner = RecordingStatusRunner { _, _ in ok("git@github.com:octo/repo.git\n") } + let found = await WorkroomStatusResolver(runner: runner).originRepository(in: "/proj") + XCTAssertEqual(found, repo) + XCTAssertEqual(runner.calls.first?.dir, "/proj") + XCTAssertEqual(runner.calls.first?.args, ["remote", "get-url", "origin"]) + let none = await WorkroomStatusResolver( + runner: MockStatusRunner { _, _ in + CommandResult( + stdout: "", stderr: "error: No such remote 'origin'", exitCode: 2, timedOut: false) + } + ).originRepository(in: "/proj") + XCTAssertNil(none) + } + /// REGRESSION. The PR panel, checks list and CI badge all depend on this answer now, and they used /// to survive a transient `gh` failure through `ghPreflight`. A lookup that collapsed every failure /// into "no repository" would blank a good panel on a blip. From 349d2a33f371fa244063ddb475f19cbdfac3f8d3 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Thu, 8 Oct 2026 11:33:18 +0100 Subject: [PATCH 4/4] fix: address PR review findings (#375) Codex: Debug builds now always bundle the Linux agents. Remote workrooms show in every build, so a Debug app that skipped them offered Docker, Apple Container and boxd and then failed every create with noBundledAgent. The WR_AGENT_LINUX opt-in is gone; CI's app job installs the musl targets and cargo-zigbuild. Measured locally: the first Debug build costs about 2 minutes more, later incremental builds no measurable extra. Copilot: the gated-restore test keeps one host ID across its creating and serving snapshots, so it models the same host becoming reachable rather than a replaced one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 16 +++++ docs/designs/remote-workrooms.md | 9 ++- macapp/AGENTS.md | 10 +-- macapp/Scripts/build-agent.sh | 13 ++-- macapp/Scripts/build-agent_test.sh | 63 +++++++------------ .../Core/Session/PersistentSessionPaths.swift | 4 +- .../BoxdIntegrationTests.swift | 6 +- .../SessionRestoreTests.swift | 3 +- vcs/scripts/ssh-fixture/run.sh | 2 +- 9 files changed, 59 insertions(+), 67 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0767e983f..af623f8bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -398,6 +398,22 @@ jobs: archive: aarch64-macos sha256: "b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489" + # The Debug build bundles the Linux agents too (#227), cross-built by build-agent.sh with + # cargo-zigbuild and the Zig above. + - name: Add rustup targets (Linux agents) + run: rustup target add --toolchain stable aarch64-unknown-linux-musl x86_64-unknown-linux-musl + + - name: Cache cargo-zigbuild + id: zigbuild + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/cargo-zigbuild + key: cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.4 + + - name: Install cargo-zigbuild + if: steps.zigbuild.outputs.cache-hit != 'true' + run: cargo install cargo-zigbuild --locked --version 0.23.4 + # Keyed on the pinned engine sha, as in `agent-terminal-state`: a bump refetches and # rebuilds, nothing else does. - name: Cache libghostty-vt diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index 2b22ebe5f..5bea1ba98 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -1478,8 +1478,7 @@ these are the subsystems that actually gate "a remote workroom is a real workroo snapshot, a 0.5 s restore, then the reboot), not timed end to end. - **Tests.** `BoxdHostDriverTests` (CI) pin the ssh-block parsing, the not-found matching, the org check and the rollback with the CLI stubbed. `BoxdIntegrationTests` run on real boxd - machines, only with `TEST_RUNNER_WR_BOXD_TESTS=1`, the sandbox off and a build with - `WR_AGENT_LINUX=1`. They + machines, only with `TEST_RUNNER_WR_BOXD_TESTS=1` and the sandbox off. They cover a derive returning only once its agent answers, even with the base's supervisor made slow (red without the wait), and distinct ssh host keys, machine-ids and boot_ids across two instances. Every fresh boxd @@ -1582,8 +1581,8 @@ these are the subsystems that actually gate "a remote workroom is a real workroo `RemoteHosts` makes the one `ContainerHostDriver`: `docker` from the usual install paths (a GUI app's PATH has none), the image from the hidden `remoteHostImage` setting, an ed25519 key it makes under `Application Support/Workroom//remote`, and the label - `workroom.provisioner=`, so one build's sweep never takes another's hosts. A Debug - build carries no Linux agent unless built with `WR_AGENT_LINUX=1`. + `workroom.provisioner=`, so one build's sweep never takes another's hosts. Every + build, Debug included, carries the Linux agents. - **The sequence** (`RemoteWorkrooms.create`): the project's GitHub repository from `gh` (github.com only, since the broker mints for it alone); its base, built and recorded on the project if it has none, cloned at `/home/workroom/`; then a name, taken before the @@ -3364,7 +3363,7 @@ disagreement passes every test on either side alone while presenting as an empty (`over_ssh_the_bootstrap_installs_hands_off_and_survives_a_crashed_restore`). What is unrecoverable is the same as locally; it now costs one connect's sessions rather than a host, and until the app is fixed every connect pushes that binary again. - - *This build has no agent for the host* (a Debug build without `WR_AGENT_LINUX=1`): a host + - *This build has no agent for the host* (a bundle missing its `wr-agent-linux-*`): a host already holding one is connected to as it is, and a host with none is an error naming the architecture. A host that is not a Linux this app builds for is refused before anything is pushed. diff --git a/macapp/AGENTS.md b/macapp/AGENTS.md index 6c12b3c55..765f41ea1 100644 --- a/macapp/AGENTS.md +++ b/macapp/AGENTS.md @@ -167,13 +167,13 @@ the app**: without it a reattaching pane repaints blank, which only shows up aft quit-and-relaunch. `wr-agent protocol` reports `terminal-state yes|no`, and the build test asserts it against the shipped binary. -**Linux agents (issue #227).** Release and Nightly builds also put a static musl `wr-agent` per +**Linux agents (issue #227).** Every build also puts a static musl `wr-agent` per Linux arch in `Contents/Resources/wr-agent-linux-{aarch64,x86_64}`, for pushing to remote hosts. Both arches ship always, whatever `ARCHS` says, because a remote box's arch has nothing to do with -the Mac's. They are not codesigned; the app's signature seals them as resources. Debug skips them -(and removes stale ones) unless `WR_AGENT_LINUX=1`. Building them needs `cargo install +the Mac's. They are not codesigned; the app's signature seals them as resources. Debug builds them +too, since every build offers remote workrooms. Building them needs `cargo install cargo-zigbuild --locked` and `rustup target add aarch64-unknown-linux-musl -x86_64-unknown-linux-musl`, which the release workflows install. `release.sh` asserts both ELFs are +x86_64-unknown-linux-musl`, which the CI and release workflows install. `release.sh` asserts both ELFs are present and static, and the `agent-linux` CI job runs `protocol` on each under Linux. `AgentBootstrap.connect` (#231) pushes the matching one to a remote host on first connect, beside the agent's socket, and hands the running agent off to it; the far side is @@ -225,7 +225,7 @@ is told its org and account (`Configuration.org`, `Configuration.account`; every account's org is nil, so the org alone cannot tell two accounts apart) and refuses to act while another is active (#356). Machines and snapshots are named `-`, so cleanup and a relaunched app find them by ID. `BoxdIntegrationTests` -make real machines: they run only with `TEST_RUNNER_WR_BOXD_TESTS=1`, a `WR_AGENT_LINUX=1` build, +make real machines: they run only with `TEST_RUNNER_WR_BOXD_TESTS=1` and the sandbox off. The app reaches it from New Workroom › Remote › boxd (#356): `RemoteHosts` holds any driver by `DriverKey` (a container runtime or a boxd org and account), a background read of a boxd host asks boxd first and leaves an asleep box asleep, and an idle host's connection is diff --git a/macapp/Scripts/build-agent.sh b/macapp/Scripts/build-agent.sh index 57c5f7f8f..75c752f70 100755 --- a/macapp/Scripts/build-agent.sh +++ b/macapp/Scripts/build-agent.sh @@ -9,8 +9,8 @@ # not collide with the app executable "Workroom" on a case-insensitive filesystem. It goes there # because that is where `PersistentSessionPaths.binaryURL(for:)` looks, beside workroom-session. # -# Release and Nightly builds also put a static Linux agent per arch in Contents/Resources, for remote -# hosts. See the end of this file. +# Every build also puts a static Linux agent per arch in Contents/Resources, for remote hosts. See +# the end of this file. # # Env vars provided by Xcode: SRCROOT, TARGET_BUILD_DIR, EXECUTABLE_FOLDER_PATH, # UNLOCALIZED_RESOURCES_FOLDER_PATH, EXPANDED_CODE_SIGN_IDENTITY, ARCHS, DERIVED_FILE_DIR, @@ -181,14 +181,9 @@ fi # Resources, not MacOS, and not codesigned. codesign sees an ELF as data, and the app's own # signature seals it like any other resource. # -# Release and Nightly only, unless WR_AGENT_LINUX=1. They are two more release builds of the agent, -# and a Debug app has no remote host to push them to yet. A build that skips them also removes any -# that an earlier opt-in build left, so a bundle never carries a stale agent. +# Every configuration, Debug included: every build offers remote workrooms, and a host with no agent +# needs one pushed from this bundle. Cargo caches both builds, so only the first costs a compile. RES_DIR="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" -if [ "${CONFIGURATION:-Debug}" = "Debug" ] && [ "${WR_AGENT_LINUX:-}" != "1" ]; then - rm -f "$RES_DIR/${HELPER_NAME}-linux-"* - exit 0 -fi if ! command -v cargo-zigbuild >/dev/null 2>&1; then echo "error: the Linux agents need cargo-zigbuild. Run 'cargo install cargo-zigbuild --locked'." >&2 diff --git a/macapp/Scripts/build-agent_test.sh b/macapp/Scripts/build-agent_test.sh index 1b64d96c8..de8af924b 100755 --- a/macapp/Scripts/build-agent_test.sh +++ b/macapp/Scripts/build-agent_test.sh @@ -33,6 +33,17 @@ else done fi +# Every configuration builds the Linux agents (#227), so every case needs cargo-zigbuild and both +# musl targets. +LINUX=1 +command -v cargo-zigbuild >/dev/null 2>&1 || LINUX=0 +for t in aarch64-unknown-linux-musl x86_64-unknown-linux-musl; do + command -v rustup >/dev/null 2>&1 \ + && rustup target list --installed --toolchain "${WR_AGENT_RUST_TOOLCHAIN:-stable}" 2>/dev/null | grep -qx "$t" \ + || LINUX=0 +done +[ "$LINUX" -eq 1 ] || { echo "build-agent_test: SKIP (cargo-zigbuild or musl targets not installed)"; exit 0; } + # Template-less `mktemp -d` is not $TMPDIR-aware on macOS (BSD mktemp asks # confstr(_CS_DARWIN_USER_TEMP_DIR)), which makes the suite unrunnable in a sandbox that only # grants $TMPDIR. Naming the template honours $TMPDIR on both BSD and GNU mktemp. @@ -43,9 +54,6 @@ trap 'rm -rf "$WORK"' EXIT # the developer's build tree. The agent includes the VCS backends, so this is a full Cargo build. REPO="$(cd "$DIR/../.." && pwd)" export CARGO_TARGET_DIR="$WORK/cargo-target" -# Set only by the case that tests it. Inherited from the developer's shell, it would make every -# Debug case build the Linux agents and the stale-removal case fail. -unset WR_AGENT_LINUX # run_agent [CONFIGURATION] -> sets $OUT to the built helper path, $RC to # the exit code. CONFIGURATION defaults to unset, which the script treats as Debug. @@ -161,32 +169,6 @@ expect_failure unknown_arch "arm64 ppc64" "unsupported arch" # iterations; that must be a readable error, not a bash "unbound variable" death. expect_failure blank_archs " " "no architectures" -# A Debug build ships no Linux agents, including ones an earlier WR_AGENT_LINUX=1 build left behind -# in the same bundle. -mkdir -p "$WORK/out-debug_stale/Resources" -: >"$WORK/out-debug_stale/Resources/wr-agent-linux-aarch64" -run_agent debug_stale "$HOST_ARCH" -if [ "$RC" -ne 0 ]; then - echo "FAIL: Debug build exited $RC, want 0. Log:" - sed 's/^/ /' "$WORK/out-debug_stale/log" - fails=$((fails + 1)) -elif ls "$WORK/out-debug_stale/Resources"/wr-agent-linux-* >/dev/null 2>&1; then - echo "FAIL: Debug build left a Linux agent in Resources" - fails=$((fails + 1)) -fi - -# A Release build ships a static Linux agent for EVERY Linux arch, whatever ARCHS says: the remote -# box's arch has nothing to do with the Mac's. Needs cargo-zigbuild and both musl targets, so it -# skips without them like the universal cases. The Linux CI job runs `protocol` on the ELFs, which -# this Mac cannot. -LINUX=1 -command -v cargo-zigbuild >/dev/null 2>&1 || LINUX=0 -for t in aarch64-unknown-linux-musl x86_64-unknown-linux-musl; do - command -v rustup >/dev/null 2>&1 \ - && rustup target list --installed --toolchain "${WR_AGENT_RUST_TOOLCHAIN:-stable}" 2>/dev/null | grep -qx "$t" \ - || LINUX=0 -done - # expect_linux_agents : both ELFs present, static, and the right arch. expect_linux_agents() { if [ "$RC" -ne 0 ]; then @@ -209,18 +191,17 @@ expect_linux_agents() { done } -if [ "$LINUX" -eq 1 ]; then - run_agent release_linux "$HOST_ARCH" Release - expect_linux_agents release_linux - # The Debug opt-in. Reuses the Linux builds above, so it costs a copy, not a compile. - WR_AGENT_LINUX=1 - export WR_AGENT_LINUX - run_agent debug_optin "$HOST_ARCH" - unset WR_AGENT_LINUX - expect_linux_agents debug_optin -else - echo "build-agent_test: SKIP Linux agent cases (cargo-zigbuild or musl targets not installed)" -fi +# Every build ships a static Linux agent for EVERY Linux arch, whatever ARCHS says: the remote box's +# arch has nothing to do with the Mac's. Debug included, since every build offers remote workrooms; +# its stale file must come back as a real agent. The Linux CI job runs `protocol` on the ELFs, which +# this Mac cannot. +mkdir -p "$WORK/out-debug_linux/Resources" +: >"$WORK/out-debug_linux/Resources/wr-agent-linux-aarch64" +run_agent debug_linux "$HOST_ARCH" +expect_linux_agents debug_linux +# Reuses the Linux builds above, so it costs a copy, not a compile. +run_agent release_linux "$HOST_ARCH" Release +expect_linux_agents release_linux if [ "$fails" -eq 0 ]; then echo "build-agent_test: OK" diff --git a/macapp/WorkroomApp/Core/Session/PersistentSessionPaths.swift b/macapp/WorkroomApp/Core/Session/PersistentSessionPaths.swift index 9f2caf64f..a457c4370 100644 --- a/macapp/WorkroomApp/Core/Session/PersistentSessionPaths.swift +++ b/macapp/WorkroomApp/Core/Session/PersistentSessionPaths.swift @@ -38,8 +38,8 @@ enum PersistentSessionPaths { /// The Linux agent the app bundles for `architecture` (`uname -m` on the host: `aarch64` or /// `x86_64`), for pushing to a remote host (`AgentBootstrap`, #231). Put in - /// `Contents/Resources` by `build-agent.sh`, sealed as a resource rather than codesigned. Nil on - /// a Debug build, which carries none unless built with `WR_AGENT_LINUX=1`. + /// `Contents/Resources` by `build-agent.sh`, sealed as a resource rather than codesigned. Nil + /// only for a bundle missing that file. static func linuxAgentURL(architecture: String) -> URL? { guard let url = Bundle.main.resourceURL?.appendingPathComponent( diff --git a/macapp/WorkroomAppTests/BoxdIntegrationTests.swift b/macapp/WorkroomAppTests/BoxdIntegrationTests.swift index b0e2f5350..82a4d4fbd 100644 --- a/macapp/WorkroomAppTests/BoxdIntegrationTests.swift +++ b/macapp/WorkroomAppTests/BoxdIntegrationTests.swift @@ -6,9 +6,9 @@ import XCTest /// `BoxdHostDriver` on real boxd machines (#256): bases, workrooms derived from them through the /// portable path, and what each failure leaves. These make and remove real machines on the /// signed-in boxd account, so they run only when asked for, with the sandbox off (the CLI's gRPC -/// does not go through the sandbox's proxy) and a Debug build that bundles the Linux agents: +/// does not go through the sandbox's proxy): /// -/// TEST_RUNNER_WR_BOXD_TESTS=1 WR_AGENT_LINUX=1 \ +/// TEST_RUNNER_WR_BOXD_TESTS=1 \ /// make app-test APP_TEST_FLAGS=-only-testing:WorkroomAppTests/BoxdIntegrationTests /// /// `TEST_RUNNER_WR_BOXD_CLI` names the CLI if it is not `~/.local/bin/boxd`. Every machine and @@ -26,7 +26,7 @@ final class BoxdIntegrationTests: XCTestCase { throw XCTSkip("set TEST_RUNNER_WR_BOXD_TESTS=1 to run these on boxd") } guard PersistentSessionPaths.linuxAgentURL(architecture: "x86_64") != nil else { - throw XCTSkip("this build bundles no Linux agent; build it with WR_AGENT_LINUX=1") + throw XCTSkip("this build bundles no Linux agent") } cli = URL( fileURLWithPath: environment["WR_BOXD_CLI"] diff --git a/macapp/WorkroomAppTests/SessionRestoreTests.swift b/macapp/WorkroomAppTests/SessionRestoreTests.swift index 761a6fc02..fecbfe74b 100644 --- a/macapp/WorkroomAppTests/SessionRestoreTests.swift +++ b/macapp/WorkroomAppTests/SessionRestoreTests.swift @@ -477,13 +477,14 @@ final class SessionRestoreTests: XCTestCase { store.terminals.makeView = { _, cwd, _ in GhosttySurfaceView(workingDirectory: cwd, spawnsSurface: false) } + let hostID = UUID() func project(_ state: String?) -> Project { Project( path: "/proj", vcs: "git", workrooms: [ Workroom( name: "r", path: "/home/workroom/r", vcsName: "workroom/r", warnings: [], - host: HostDescriptor(state: state, provisioner: RemoteWorkrooms.provisioner, id: UUID()) + host: HostDescriptor(state: state, provisioner: RemoteWorkrooms.provisioner, id: hostID) ) ]) } diff --git a/vcs/scripts/ssh-fixture/run.sh b/vcs/scripts/ssh-fixture/run.sh index 69129b885..851e12f1b 100755 --- a/vcs/scripts/ssh-fixture/run.sh +++ b/vcs/scripts/ssh-fixture/run.sh @@ -122,7 +122,7 @@ for attempt in $(seq 1 100); do done # AGENT: the ELF itself, for the tests of the bootstrap that pushes it (#231). The app's tests -# take it as their bundled agent, since a Debug build carries no Linux agent of its own. CONTAINER, +# take it as their bundled agent. CONTAINER, # RUNTIME and SCREENS: for the test that reboots the box (#232). IMAGE, RUNTIME_PATH, PUBLIC_KEY # and LABEL: for the app's tests that provision containers of their own from the image (#252), # and CLONE_TOKEN and BROKER_URL for the image's GitHub and broker (fake-github.py).