diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42ead0c62..af623f8bd 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. @@ -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/.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/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/docs/designs/oq8-cross-machine-reattach.md b/docs/designs/oq8-cross-machine-reattach.md index 2fe3e1347..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 @@ -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..5bea1ba98 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 @@ -24,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. @@ -1476,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 @@ -1580,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 @@ -3346,7 +3347,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 @@ -3359,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. @@ -3904,8 +3908,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..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 @@ -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 @@ -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/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/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 583a6ac71..1718ce0dc 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 @@ -3623,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/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/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[.. 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/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/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/Core/WorkroomStatusResolver.swift b/macapp/WorkroomApp/Core/WorkroomStatusResolver.swift index 0888fcf2a..02825d56f 100644 --- a/macapp/WorkroomApp/Core/WorkroomStatusResolver.swift +++ b/macapp/WorkroomApp/Core/WorkroomStatusResolver.swift @@ -215,6 +215,15 @@ struct WorkroomStatusResolver: Sendable { return .found(repo) } + /// The GitHub repository `dir`'s `origin` remote names, from git alone (#260): for a remote + /// workroom's create when `gh` can't answer, since Codaset sign-in is enough for one and needs no + /// `gh`. nil with no origin, or one that names no GitHub-shaped repository. + func originRepository(in dir: String) async -> 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/WorkroomApp/Views/NewWorkroomDialog.swift b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift index 2e942c192..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? @@ -54,14 +53,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/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/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/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..fecbfe74b 100644 --- a/macapp/WorkroomAppTests/SessionRestoreTests.swift +++ b/macapp/WorkroomAppTests/SessionRestoreTests.swift @@ -473,21 +473,23 @@ 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 = [ + 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(provisioner: RemoteWorkrooms.provisioner, id: UUID())) + host: HostDescriptor(state: state, provisioner: RemoteWorkrooms.provisioner, id: hostID) + ) ]) - ] + } + // 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 +508,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 +523,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 +598,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 +681,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 +723,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 +774,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 +847,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 +908,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 +960,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 +1018,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 +1058,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 +1109,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 @@ -1199,9 +1179,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. - 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..7c77a0544 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() { @@ -141,11 +135,8 @@ 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() { - defer { RemoteWorkrooms.enabledForTesting = nil } - RemoteWorkrooms.enabledForTesting = true let mine = RemoteWorkrooms.provisioner func detail(_ host: HostDescriptor) -> String { let target = Workroom( @@ -166,8 +157,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/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. 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: []) - } } 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).