From b3c41811751d2087773471f56ffb8cdcf576393c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:44:25 -0400 Subject: [PATCH 1/3] Release pipeline: mac-only DMG surface, brew tap, update-impact warnings - release-core.yml: comment out Windows build + publish; publish only the 4-asset macOS set (dmg + zip + blockmap + latest-mac.yml) that electron-updater requires; runtime binaries still build as app sidecars but are no longer published as release assets - validators: add ade-orchestrator to bundledAgentSkills (mac + win) - new update install impact probe (connected phones via sync status, in-process + runtime-backed) surfaced in the pre-install confirm and the quit dialog, with ADE Code / agent-session disconnect copy - scripts/update-brew-tap.sh + /release skill phase 8 updated for mac-only assets and post-publish cask bump; README gets brew install command Co-Authored-By: Claude Fable 5 --- .agents/skills/release/SKILL.md | 21 +- .github/workflows/release-core.yml | 232 ++++++++++-------- README.md | 12 +- .../scripts/validate-mac-artifacts.mjs | 1 + .../scripts/validate-win-artifacts.mjs | 1 + apps/desktop/src/main/main.ts | 91 ++++++- .../src/main/services/ipc/registerIpc.ts | 13 + apps/desktop/src/preload/global.d.ts | 2 + apps/desktop/src/preload/preload.ts | 3 + apps/desktop/src/renderer/browserMock.ts | 1 + .../components/app/AutoUpdateControl.tsx | 28 ++- apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/core.ts | 14 ++ scripts/update-brew-tap.sh | 54 ++++ 14 files changed, 346 insertions(+), 128 deletions(-) create mode 100755 scripts/update-brew-tap.sh diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 4c875fd5f..f1c02dc05 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -465,17 +465,17 @@ Before printing the summary, verify the draft release carries every expected ass gh release view "v" --json assets --jq '.assets[].name' | sort ``` -Expected asset set when `scope.desktop=true`: +Expected asset set when `scope.desktop=true` (releases are macOS-only right now; +Windows publishing is commented out in `release-core.yml`): - `ADE--universal.dmg` - `ADE--universal-mac.zip` - `ADE--universal-mac.zip.blockmap` - `latest-mac.yml` -- `ADE--win-x64.exe` -- `ADE--win-x64.exe.blockmap` -- `latest.yml` -If any macOS asset is missing → mac build or upload broke; re-inspect the `build-mac-release` job. -If any Windows asset is missing → `build-win-release` broke or its artifact upload failed; re-inspect that job. Do not flip the draft if Windows artifacts are missing — shipping an asymmetric desktop release will confuse electron-updater consumers on the missing platform. +These four are the complete set — the zip + blockmap + `latest-mac.yml` are what +electron-updater consumes for auto-update (macOS updates install from the zip, +not the DMG), so a release missing any of them silently bricks auto-update. +If any asset is missing → mac build or upload broke; re-inspect the `build-mac-release` job. Then print a single final block and stop: @@ -484,12 +484,17 @@ Release v — summary - Changelog: https://www.ade-app.dev/docs/changelog/v - Draft release: (still draft — flip manually) -- Desktop assets: mac=, windows= +- Desktop assets: mac= - Workflow run: (conclusion: success) - iOS TestFlight build : - Beta group: -Next step: review the draft release, then `gh release edit v --draft=false` to publish. +Next steps: +1. Review the draft release, then `gh release edit v --draft=false` to publish. +2. After publishing, bump the Homebrew tap: `scripts/update-brew-tap.sh v` + (updates the cask version + sha256 in arul28/homebrew-ade so + `brew install --cask arul28/ade/ade` serves the new DMG. It refuses to run + against a draft, so it cannot be done before step 1.) ``` If any phase ended in `blocked`, the summary says `BLOCKED` at the top with the failing phase and the command to resume. diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 087e6c0d1..4a402063d 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -175,74 +175,78 @@ jobs: apps/desktop/release/latest-mac.yml if-no-files-found: error - build-win-release: - needs: - - verify - - build-runtime-binaries - runs-on: windows-latest - concurrency: - group: release-${{ inputs.release_tag }}-win - cancel-in-progress: true - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.target_ref }} - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: | - apps/desktop/package-lock.json - apps/ade-cli/package-lock.json - - - name: Install desktop dependencies - run: cd apps/desktop && npm ci - - - name: Install ADE CLI dependencies - run: cd apps/ade-cli && npm ci - - - name: Download ADE runtime binaries - uses: actions/download-artifact@v4 - with: - pattern: ade-runtime-* - path: apps/desktop/resources/runtime - merge-multiple: true - - - name: Materialize ADE runtime resources - env: - ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}\apps\desktop\resources\runtime - run: cd apps/desktop && npm run materialize:runtime-resources - - - name: Stamp release version - env: - ADE_RELEASE_TAG: ${{ inputs.release_tag }} - run: cd apps/desktop && npm run version:release - - - name: Reset release output - shell: pwsh - run: | - Remove-Item -Recurse -Force apps/desktop/release, apps/desktop/.cache -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Path apps/desktop/.cache | Out-Null - - - name: Build and validate Windows release - env: - ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron - ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder - CSC_LINK: ${{ ((secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD)) && (secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) || '' }} - CSC_KEY_PASSWORD: ${{ ((secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD)) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD) || '' }} - run: cd apps/desktop && npm run dist:win - - - name: Upload validated Windows artifacts to workflow run - uses: actions/upload-artifact@v4 - with: - name: ade-win-release-${{ inputs.release_tag }} - path: | - apps/desktop/release/*.exe - apps/desktop/release/*.exe.blockmap - apps/desktop/release/latest.yml - if-no-files-found: error + # Windows release builds are disabled for now — ADE ships macOS-only releases. + # To re-enable: uncomment this job, add `build-win-release` back to + # publish-release `needs`, and restore the win blocks in the publish job + # (artifact download, manifest validation, upload list). + # build-win-release: + # needs: + # - verify + # - build-runtime-binaries + # runs-on: windows-latest + # concurrency: + # group: release-${{ inputs.release_tag }}-win + # cancel-in-progress: true + # steps: + # - uses: actions/checkout@v4 + # with: + # ref: ${{ inputs.target_ref }} + # fetch-depth: 0 + # + # - uses: actions/setup-node@v4 + # with: + # node-version: 22 + # cache: npm + # cache-dependency-path: | + # apps/desktop/package-lock.json + # apps/ade-cli/package-lock.json + # + # - name: Install desktop dependencies + # run: cd apps/desktop && npm ci + # + # - name: Install ADE CLI dependencies + # run: cd apps/ade-cli && npm ci + # + # - name: Download ADE runtime binaries + # uses: actions/download-artifact@v4 + # with: + # pattern: ade-runtime-* + # path: apps/desktop/resources/runtime + # merge-multiple: true + # + # - name: Materialize ADE runtime resources + # env: + # ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}\apps\desktop\resources\runtime + # run: cd apps/desktop && npm run materialize:runtime-resources + # + # - name: Stamp release version + # env: + # ADE_RELEASE_TAG: ${{ inputs.release_tag }} + # run: cd apps/desktop && npm run version:release + # + # - name: Reset release output + # shell: pwsh + # run: | + # Remove-Item -Recurse -Force apps/desktop/release, apps/desktop/.cache -ErrorAction SilentlyContinue + # New-Item -ItemType Directory -Path apps/desktop/.cache | Out-Null + # + # - name: Build and validate Windows release + # env: + # ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron + # ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder + # CSC_LINK: ${{ ((secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD)) && (secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) || '' }} + # CSC_KEY_PASSWORD: ${{ ((secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD)) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD) || '' }} + # run: cd apps/desktop && npm run dist:win + # + # - name: Upload validated Windows artifacts to workflow run + # uses: actions/upload-artifact@v4 + # with: + # name: ade-win-release-${{ inputs.release_tag }} + # path: | + # apps/desktop/release/*.exe + # apps/desktop/release/*.exe.blockmap + # apps/desktop/release/latest.yml + # if-no-files-found: error build-runtime-binaries: needs: verify @@ -377,7 +381,6 @@ jobs: needs: - build-runtime-binaries - build-mac-release - - build-win-release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -391,23 +394,33 @@ jobs: name: ade-mac-release-${{ inputs.release_tag }} path: release-assets/mac - - name: Download Windows release artifacts - uses: actions/download-artifact@v4 - with: - name: ade-win-release-${{ inputs.release_tag }} - path: release-assets/win - - - name: Download ADE runtime binaries - uses: actions/download-artifact@v4 - with: - pattern: ade-runtime-* - path: release-assets/runtime - merge-multiple: true - - - name: Add standalone runtime installer - run: | - cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh - chmod 755 release-assets/runtime/install.sh + # Windows artifacts and the standalone runtime assets (ade-* binaries, + # native tarballs, install.sh) are intentionally NOT published right now. + # The release surface is macOS-only and kept minimal: the DMG for humans, + # plus the zip + blockmap + latest-mac.yml that electron-updater requires + # for auto-update (macOS auto-update installs from the zip, NOT the DMG — + # removing those three assets silently bricks updates for every install). + # The runtime binaries are still BUILT above because the macOS app bundles + # them as sidecars (local brain + remote-runtime bootstrap payloads). + # To publish them again, restore the blocks below. + # + # - name: Download Windows release artifacts + # uses: actions/download-artifact@v4 + # with: + # name: ade-win-release-${{ inputs.release_tag }} + # path: release-assets/win + # + # - name: Download ADE runtime binaries + # uses: actions/download-artifact@v4 + # with: + # pattern: ade-runtime-* + # path: release-assets/runtime + # merge-multiple: true + # + # - name: Add standalone runtime installer + # run: | + # cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh + # chmod 755 release-assets/runtime/install.sh - name: Validate publish asset manifest run: | @@ -440,23 +453,26 @@ jobs: require_glob 'release-assets/mac/*.zip' 'macOS zip' require_glob 'release-assets/mac/*-mac.zip.blockmap' 'macOS blockmap' require_file 'release-assets/mac/latest-mac.yml' 'macOS auto-update metadata' - require_glob 'release-assets/win/*.exe' 'Windows installer' - require_glob 'release-assets/win/*.exe.blockmap' 'Windows blockmap' - require_file 'release-assets/win/latest.yml' 'Windows auto-update metadata' - require_file 'release-assets/runtime/install.sh' 'standalone runtime installer' - if [ ! -x 'release-assets/runtime/install.sh' ]; then - echo "::error::Standalone runtime installer is not executable." - exit 1 - fi - for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do - require_file "release-assets/runtime/ade-$target" "ADE runtime binary for $target" - require_file "release-assets/runtime/ade-$target.native.tar.gz" "ADE native dependency archive for $target" - tar -tzf "release-assets/runtime/ade-$target.native.tar.gz" | grep -q '^\./node_modules/' || { - echo "::error::ADE native dependency archive for $target is missing node_modules." - exit 1 - } - done + # Windows + standalone runtime assets are not published right now. + # Restore these checks together with the publish blocks above. + # require_glob 'release-assets/win/*.exe' 'Windows installer' + # require_glob 'release-assets/win/*.exe.blockmap' 'Windows blockmap' + # require_file 'release-assets/win/latest.yml' 'Windows auto-update metadata' + # require_file 'release-assets/runtime/install.sh' 'standalone runtime installer' + # if [ ! -x 'release-assets/runtime/install.sh' ]; then + # echo "::error::Standalone runtime installer is not executable." + # exit 1 + # fi + # + # for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do + # require_file "release-assets/runtime/ade-$target" "ADE runtime binary for $target" + # require_file "release-assets/runtime/ade-$target.native.tar.gz" "ADE native dependency archive for $target" + # tar -tzf "release-assets/runtime/ade-$target.native.tar.gz" | grep -q '^\./node_modules/' || { + # echo "::error::ADE native dependency archive for $target is missing node_modules." + # exit 1 + # } + # done - name: Create or update draft GitHub release env: @@ -466,16 +482,18 @@ jobs: GH_REPO: ${{ github.repository }} run: | shopt -s nullglob + # macOS-only release surface. The zip + blockmap + latest-mac.yml are + # required by electron-updater; the DMG is the human download. files=( release-assets/mac/*.dmg release-assets/mac/*.zip release-assets/mac/*-mac.zip.blockmap release-assets/mac/latest-mac.yml - release-assets/win/*.exe - release-assets/win/*.exe.blockmap - release-assets/win/latest.yml - release-assets/runtime/install.sh - release-assets/runtime/ade-* + # release-assets/win/*.exe + # release-assets/win/*.exe.blockmap + # release-assets/win/latest.yml + # release-assets/runtime/install.sh + # release-assets/runtime/ade-* ) if [ "${#files[@]}" -eq 0 ]; then diff --git a/README.md b/README.md index 77a2eb757..8c552350e 100644 --- a/README.md +++ b/README.md @@ -109,15 +109,19 @@ Download ADE from [**GitHub Releases**](https://github.com/arul28/ADE/releases/l ### macOS -Download the latest `.dmg`, drag **ADE.app** into `/Applications`, and open it. +With [Homebrew](https://brew.sh): + +```bash +brew install --cask arul28/ade/ade +``` + +Or download the latest `.dmg`, drag **ADE.app** into `/Applications`, and open it. Both paths install the same signed + notarized universal app; ADE keeps itself current afterwards through its built-in auto-updater. Requirements: macOS 13+, git on `PATH`, Node 22+ for headless CLI workflows. ### Windows -Download the latest Windows installer (`ADE-*-win-x64.exe`) from [**GitHub Releases**](https://github.com/arul28/ADE/releases/latest) and run it. Windows builds are published from the same release workflow as macOS and include the ADE runtime plus Windows auto-update metadata. - -Requirements: Windows x64, git on `PATH`, Node 22+ for headless CLI workflows. +Windows releases are paused for now — current releases ship macOS only. The Windows build pipeline still exists (commented out in the release workflow) and will return in a future release. ## CLI diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index d209bfc20..2a3e5aaa4 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -29,6 +29,7 @@ const bundledAgentSkills = [ "ade-proof-artifacts", "ade-macos-vm", "ade-deeplinks", + "ade-orchestrator", ]; const bundledAdeCliFiles = [ ["cli.cjs", "bundled ADE CLI entry"], diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index ee693c782..83cdbfa7a 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -32,6 +32,7 @@ const bundledAgentSkills = [ "ade-proof-artifacts", "ade-macos-vm", "ade-deeplinks", + "ade-orchestrator", ]; const bundledAdeCliFiles = [ ["cli.cjs", "bundled ADE CLI entry"], diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 2435ca786..b90a8fc7a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -102,9 +102,11 @@ import type { ProjectInfo, PtyDataEvent, SyncMobileProjectSummary, + SyncPeerConnectionState, SyncProjectConnectionPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload, + UpdateInstallImpact, } from "../shared/types"; import { buildLinearAutomationDispatches } from "./services/automations/linearAutomationDispatch"; import type { IosSimulatorDrawerMode } from "../shared/types/iosSimulator"; @@ -5672,6 +5674,63 @@ app.whenReady().then(async () => { return Array.from(new Set(labels)); }; + // Live-connection probe shown before an update install or quit. Mirrors the + // lane-delete quit probe: in-process services in dev, runtime actions against + // the brain in packaged builds. Best-effort — an unreachable runtime simply + // reports no phones. + const collectUpdateInstallImpact = async (): Promise => { + const phonesById = new Map(); + const recordPeers = (peers: readonly SyncPeerConnectionState[] | undefined): void => { + for (const peer of peers ?? []) { + if (peer.deviceType !== "phone" && peer.platform !== "iOS") continue; + phonesById.set(peer.deviceId, peer.deviceName?.trim() || "Connected phone"); + } + }; + if (shouldUseInProcessProjectRuntime()) { + for (const ctx of projectContexts.values()) { + try { + const status = await ctx.syncService?.getStatus(); + recordPeers(status?.connectedPeers); + } catch { + // Best-effort probe. + } + } + } else { + const roots = new Set([ + ...rootsBoundToWindows(), + ...projectContexts.keys(), + ]); + await Promise.all(Array.from(roots).map(async (root) => { + try { + const status = await localRuntimePool.syncStatusForRoot(root, {}); + recordPeers(status.connectedPeers); + } catch { + // Best-effort probe. + } + })); + } + return { + connectedPhones: Array.from(phonesById, ([deviceId, deviceName]) => ({ + deviceId, + deviceName, + })), + }; + }; + + // Quit/update dialogs are synchronous, so cap how long the impact probe can + // delay them. On timeout the dialog falls back to generic copy. + const collectUpdateInstallImpactBounded = async ( + timeoutMs = 1_500, + ): Promise => { + return await Promise.race([ + collectUpdateInstallImpact().catch((): UpdateInstallImpact => ({ connectedPhones: [] })), + new Promise((resolve) => { + const timer = setTimeout(() => resolve({ connectedPhones: [] }), timeoutMs); + timer.unref?.(); + }), + ]); + }; + const confirmNoRunningLaneDeleteForQuit = async (ownerWindow?: BrowserWindow | null): Promise => { const runningDeletes = await getRunningLaneDeleteLabels(); if (runningDeletes.length === 0) return true; @@ -5705,15 +5764,33 @@ app.whenReady().then(async () => { return choice === 1; }; - const confirmQuitWarning = (ownerWindow?: BrowserWindow | null): boolean => - showWindowCloseWarning(ownerWindow, { + const describeConnectedPhones = (impact: UpdateInstallImpact | null): string | null => { + const phones = impact?.connectedPhones ?? []; + if (phones.length === 0) return null; + const names = phones.map((phone) => phone.deviceName).join(", "); + return phones.length === 1 + ? `${names} is connected through ADE phone sync and will disconnect; it reconnects automatically once ADE is running again.` + : `These phones are connected through ADE phone sync and will disconnect: ${names}. They reconnect automatically once ADE is running again.`; + }; + + const confirmQuitWarning = ( + ownerWindow?: BrowserWindow | null, + impact: UpdateInstallImpact | null = null, + ): boolean => { + const phoneDetail = describeConnectedPhones(impact); + return showWindowCloseWarning(ownerWindow, { buttons: ["Keep ADE open", "Quit ADE"], title: "Quit ADE?", message: "Save your work before closing ADE.", - detail: - "Quitting ADE will end agents and background processes owned by this desktop session, including OpenCode servers, terminal sessions, and test runs. The ADE service login item keeps running separately when it is installed.", + detail: [ + "Quitting ADE will end agents and background processes owned by this desktop session, including OpenCode servers, terminal sessions, and test runs.", + phoneDetail, + "Open ADE Code terminals attached to this machine's ADE service will disconnect too — you can reopen them afterwards.", + "The ADE service login item keeps running separately when it is installed.", + ].filter(Boolean).join(" "), rememberQuitAcknowledgement: true, }); + }; const confirmCloseWindowWarning = (ownerWindow: BrowserWindow): boolean => showWindowCloseWarning(ownerWindow, { @@ -5734,7 +5811,8 @@ app.whenReady().then(async () => { void (async () => { try { if (!(await confirmNoRunningLaneDeleteForQuit(ownerWindow))) return; - if (!confirmQuitWarning(ownerWindow)) return; + const impact = await collectUpdateInstallImpactBounded(); + if (!confirmQuitWarning(ownerWindow, impact)) return; requestAppShutdown({ reason, exitCode: 0 }); } finally { quitConfirmationInFlight = false; @@ -6165,6 +6243,9 @@ app.whenReady().then(async () => { if (!ctx.autoUpdateService) { ctx.autoUpdateService = autoUpdateService; } + if (!ctx.updateInstallImpactProvider) { + ctx.updateInstallImpactProvider = () => collectUpdateInstallImpactBounded(); + } return ctx; }, getResourceUsageContexts: () => { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index d96e606a0..49902044f 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -549,6 +549,7 @@ import type { CursorCloudOpenChatRequest, CursorCloudOpenChatResult, CursorCloudStreamRunResult, + UpdateInstallImpact, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; @@ -982,6 +983,7 @@ export type AppContext = { apnsKeyStore?: import("../notifications/apnsService").ApnsKeyStore | null; notificationEventBus?: import("../notifications/notificationEventBus").NotificationEventBus | null; autoUpdateService?: ReturnType | null; + updateInstallImpactProvider?: (() => Promise) | null; feedbackReporterService?: ReturnType | null; }; @@ -10099,6 +10101,17 @@ export function registerIpc({ return getCtx().autoUpdateService?.getSnapshot() ?? createEmptyAutoUpdateSnapshot(); }); + ipcMain.handle(IPC.updateGetInstallImpact, async (): Promise => { + const provider = getCtx().updateInstallImpactProvider; + if (!provider) return { connectedPhones: [] }; + try { + return await provider(); + } catch { + // Best-effort probe: a failed impact query must never block the update UI. + return { connectedPhones: [] }; + } + }); + ipcMain.handle(IPC.updateQuitAndInstall, () => { return getCtx().autoUpdateService?.quitAndInstall() ?? false; }); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index d41525708..b8b9edc82 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -16,6 +16,7 @@ import type { LatestReleaseInfo, AppNavigationRequest, AutoUpdateSnapshot, + UpdateInstallImpact, ClearLocalAdeDataArgs, ClearLocalAdeDataResult, ArchiveLaneArgs, @@ -2310,6 +2311,7 @@ declare global { }; updateCheckForUpdates: () => Promise; updateGetState: () => Promise; + updateGetInstallImpact: () => Promise; updateQuitAndInstall: () => Promise; updateDismissInstalledNotice: () => Promise; onUpdateEvent: (cb: (snapshot: AutoUpdateSnapshot) => void) => () => void; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 47b1ebf3a..4e9e36fc8 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -22,6 +22,7 @@ import type { LatestReleaseInfo, AppNavigationRequest, AutoUpdateSnapshot, + UpdateInstallImpact, ClearLocalAdeDataArgs, ClearLocalAdeDataResult, ArchiveLaneArgs, @@ -8935,6 +8936,8 @@ contextBridge.exposeInMainWorld("ade", { updateCheckForUpdates: () => ipcRenderer.invoke(IPC.updateCheckForUpdates), updateGetState: (): Promise => ipcRenderer.invoke(IPC.updateGetState), + updateGetInstallImpact: (): Promise => + ipcRenderer.invoke(IPC.updateGetInstallImpact), updateQuitAndInstall: (): Promise => ipcRenderer.invoke(IPC.updateQuitAndInstall), updateDismissInstalledNotice: () => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 30d90884d..8b38c66b1 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -6112,6 +6112,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { error: null, recentlyInstalled: null, }), + updateGetInstallImpact: resolved({ connectedPhones: [] }), updateQuitAndInstall: resolved(true), updateDismissInstalledNotice: resolved(undefined), onUpdateEvent: noop, diff --git a/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx b/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx index 4db2e8c57..a458ecf6f 100644 --- a/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx +++ b/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx @@ -72,10 +72,30 @@ export function AutoUpdateControl() { }); }, []); - const handleRestartToInstall = useCallback(() => { - const confirmed = window.confirm( - `ADE will quit and reopen automatically to install ${versionLabel(snapshot.version)}.\n\nYou do not need to restart ADE yourself. Any unsaved work may be lost. Continue?`, + const handleRestartToInstall = useCallback(async () => { + // Best-effort probe of live connections (paired phones) so the user knows + // what drops while ADE and its brain service restart on the new version. + const impact = await window.ade.updateGetInstallImpact().catch(() => null); + const phones = impact?.connectedPhones ?? []; + const lines = [ + `ADE will quit and reopen automatically to install ${versionLabel(snapshot.version)}.`, + "", + ]; + if (phones.length === 1) { + lines.push( + `${phones[0].deviceName} is connected through ADE phone sync. It will disconnect during the update and reconnect automatically once ADE is back.`, + ); + } else if (phones.length > 1) { + lines.push( + `Connected phones (${phones.map((phone) => phone.deviceName).join(", ")}) will disconnect during the update and reconnect automatically once ADE is back.`, + ); + } + lines.push( + "Open ADE Code terminals and running agent sessions on this machine will disconnect while the ADE service restarts — you can reopen them right after the update.", + "", + "You do not need to restart ADE yourself. Any unsaved work may be lost. Continue?", ); + const confirmed = window.confirm(lines.join("\n")); if (!confirmed) return; setInstallRequested(true); void window.ade.updateQuitAndInstall() @@ -129,7 +149,7 @@ export function AutoUpdateControl() { disabled={effectiveStatus !== "ready"} onClick={() => { if (effectiveStatus === "ready") { - handleRestartToInstall(); + void handleRestartToInstall(); } }} title={indicatorTitle()} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 11a956c1f..a466659ae 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -790,6 +790,7 @@ export const IPC = { feedbackOnUpdate: "ade.feedback.onUpdate", updateCheckForUpdates: "ade.update.checkForUpdates", updateGetState: "ade.update.getState", + updateGetInstallImpact: "ade.update.getInstallImpact", updateQuitAndInstall: "ade.update.quitAndInstall", updateDismissInstalledNotice: "ade.update.dismissInstalledNotice", updateEvent: "ade.update.event", diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 76651f23a..481c0e9d0 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -107,6 +107,20 @@ export type AutoUpdateSnapshot = { recentlyInstalled: RecentlyInstalledUpdate | null; }; +export type UpdateInstallImpactPhone = { + deviceId: string; + deviceName: string; +}; + +/** + * Live connections that drop while ADE (and its brain service) restarts for an + * update. Best-effort: probes are time-boxed, so an empty list means "none + * detected", not a guarantee. + */ +export type UpdateInstallImpact = { + connectedPhones: UpdateInstallImpactPhone[]; +}; + export type ProjectInfo = { rootPath: string; displayName: string; diff --git a/scripts/update-brew-tap.sh b/scripts/update-brew-tap.sh new file mode 100755 index 000000000..51e1dad46 --- /dev/null +++ b/scripts/update-brew-tap.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Bump the arul28/homebrew-ade cask to a published ADE release. +# +# Run after the GitHub release is PUBLISHED (not draft) — the cask download +# URL only resolves for published releases. Requires `gh` authenticated as an +# account with push access to arul28/homebrew-ade. +# +# Usage: +# scripts/update-brew-tap.sh v1.2.3 +# scripts/update-brew-tap.sh latest +set -euo pipefail + +tag="${1:?usage: update-brew-tap.sh }" +repo="arul28/ADE" +tap_repo="arul28/homebrew-ade" + +if [ "$tag" = "latest" ]; then + tag="$(gh api "repos/$repo/releases/latest" --jq .tag_name)" +fi +version="${tag#v}" + +release_json="$(gh api "repos/$repo/releases/tags/$tag")" +if [ "$(printf '%s' "$release_json" | jq -r .draft)" = "true" ]; then + echo "error: $tag is still a draft release — publish it first." >&2 + exit 1 +fi + +asset_name="ADE-$version-universal.dmg" +digest="$(printf '%s' "$release_json" | jq -r --arg name "$asset_name" \ + '.assets[] | select(.name == $name) | .digest // empty')" +case "$digest" in + sha256:*) sha="${digest#sha256:}" ;; + *) + echo "error: no sha256 digest found for $asset_name on $tag." >&2 + exit 1 + ;; +esac + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +gh repo clone "$tap_repo" "$tmp_dir/tap" -- -q --depth 1 +cask="$tmp_dir/tap/Casks/ade.rb" + +sed -i '' -E "s|^ version \".*\"$| version \"$version\"|" "$cask" +sed -i '' -E "s|^ sha256 \".*\"$| sha256 \"$sha\"|" "$cask" + +if git -C "$tmp_dir/tap" diff --quiet; then + echo "Cask already at ADE $version — nothing to do." + exit 0 +fi + +git -C "$tmp_dir/tap" commit -aqm "ade $version" +git -C "$tmp_dir/tap" push -q +echo "Updated $tap_repo cask to ADE $version (sha256 $sha)." From 65952430fb587d2c7af3afd2a653e02173be851c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:06:57 -0400 Subject: [PATCH 2/3] Close isolated-brain gap, automate brew tap bump, scrub stale headless-install docs - isolated recovery now re-attempts the service install (60s cooldown) when the primary-socket probe fails, instead of probing forever against a socket nothing will ever bind; regression test added - runtimeMode (primary|isolated) exposed in LocalRuntimeStatus, shown in Settings > About, and surfaced via native notifications on degrade/restore - update-brew-tap.yml bumps the arul28/homebrew-ade cask on release publish using a write deploy key (HOMEBREW_TAP_DEPLOY_KEY secret, already set) - ade-cli README / ARCHITECTURE / remote-runtime docs now state that runtime release assets are unpublished and SSH bootstrap uses bundled sidecars Co-Authored-By: Claude Fable 5 --- .agents/skills/release/SKILL.md | 8 +- .github/workflows/update-brew-tap.yml | 74 +++++++++++++++++++ apps/ade-cli/README.md | 2 + apps/desktop/src/main/main.ts | 23 +++++- .../localRuntimeConnectionPool.test.ts | 46 ++++++++++++ .../localRuntimeConnectionPool.ts | 51 ++++++++++++- apps/desktop/src/renderer/browserMock.ts | 1 + .../components/settings/AboutSection.tsx | 4 +- apps/desktop/src/shared/types/core.ts | 7 ++ docs/ARCHITECTURE.md | 2 +- docs/features/remote-runtime/README.md | 2 +- 11 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/update-brew-tap.yml diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index f1c02dc05..d01e22fb3 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -491,10 +491,10 @@ Release v — summary Next steps: 1. Review the draft release, then `gh release edit v --draft=false` to publish. -2. After publishing, bump the Homebrew tap: `scripts/update-brew-tap.sh v` - (updates the cask version + sha256 in arul28/homebrew-ade so - `brew install --cask arul28/ade/ade` serves the new DMG. It refuses to run - against a draft, so it cannot be done before step 1.) +2. Publishing automatically bumps the Homebrew tap (arul28/homebrew-ade) via the + `update-brew-tap.yml` workflow — verify with + `gh run list --workflow update-brew-tap.yml --limit 1` after publishing. + Manual fallback if that run fails: `scripts/update-brew-tap.sh v`. ``` If any phase ended in `blocked`, the summary says `BLOCKED` at the top with the failing phase and the command to resume. diff --git a/.github/workflows/update-brew-tap.yml b/.github/workflows/update-brew-tap.yml new file mode 100644 index 000000000..6c09ddcd3 --- /dev/null +++ b/.github/workflows/update-brew-tap.yml @@ -0,0 +1,74 @@ +name: Update brew tap + +# Bumps the arul28/homebrew-ade cask (version + sha256) whenever a release is +# published, so `brew install --cask arul28/ade/ade` always serves the latest +# DMG. Fires on publish (not draft creation) because the cask download URL only +# resolves for published releases. +# +# Auth: HOMEBREW_TAP_DEPLOY_KEY repo secret — an SSH deploy key with write +# access on arul28/homebrew-ade. Manual fallback: scripts/update-brew-tap.sh. + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + bump-cask: + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + steps: + - name: Resolve DMG asset digest + id: dmg + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + version="${TAG_NAME#v}" + asset_name="ADE-$version-universal.dmg" + digest="$(gh api "repos/$GH_REPO/releases/tags/$TAG_NAME" \ + --jq ".assets[] | select(.name == \"$asset_name\") | .digest // empty")" + case "$digest" in + sha256:*) sha="${digest#sha256:}" ;; + *) + echo "::error::No sha256 digest found for $asset_name on $TAG_NAME." + exit 1 + ;; + esac + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + - name: Update cask in arul28/homebrew-ade + env: + DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + VERSION: ${{ steps.dmg.outputs.version }} + SHA256: ${{ steps.dmg.outputs.sha }} + run: | + set -euo pipefail + if [ -z "$DEPLOY_KEY" ]; then + echo "::error::Missing HOMEBREW_TAP_DEPLOY_KEY secret (write deploy key for arul28/homebrew-ade)." + exit 1 + fi + mkdir -p ~/.ssh + printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/tap_deploy_key + chmod 600 ~/.ssh/tap_deploy_key + ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null + export GIT_SSH_COMMAND="ssh -i ~/.ssh/tap_deploy_key -o IdentitiesOnly=yes" + + git clone -q --depth 1 git@github.com:arul28/homebrew-ade.git tap + cd tap + sed -i -E "s|^ version \".*\"$| version \"$VERSION\"|" Casks/ade.rb + sed -i -E "s|^ sha256 \".*\"$| sha256 \"$SHA256\"|" Casks/ade.rb + if git diff --quiet; then + echo "Cask already at ADE $VERSION — nothing to do." + exit 0 + fi + git config user.name "ade-release-bot" + git config user.email "release-bot@users.noreply.github.com" + git commit -aqm "ade $VERSION" + git push -q origin HEAD:main + echo "Updated cask to ADE $VERSION (sha256 $SHA256)." diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index b050276e0..956f527a6 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -46,6 +46,8 @@ Three ways to put `ade` on a machine: 1. **Standalone runtime install** — single static binary plus its native dependency archive, fetched from a GitHub release. Suitable for headless macOS/Linux servers. + > **Currently unavailable:** releases ship macOS desktop assets only — the runtime binaries and `install.sh` are built by `release-core.yml` but not published as release assets (the publish block is commented out there). The script below only works once that block is re-enabled. Headless machines reached over SSH don't need it: the desktop app uploads its bundled runtime binaries on first connect. + ```bash curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh ``` diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index b90a8fc7a..18d490119 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, nativeImage, protocol, safeStorage, shell } from "electron"; +import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, nativeImage, Notification, protocol, safeStorage, shell } from "electron"; import { AsyncLocalStorage } from "node:async_hooks"; import os from "node:os"; import path from "node:path"; @@ -1201,6 +1201,27 @@ app.whenReady().then(async () => { && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1"; const localRuntimePool = new LocalRuntimeConnectionPool(app.getVersion(), localRuntimeLogger, { preferServiceRepair: shouldRepairRuntimeServiceOnFallback, + onRuntimeModeChange: (mode) => { + localRuntimeLogger.warn("local_runtime.runtime_mode_changed", { mode }); + if (!Notification.isSupported()) return; + try { + const notification = mode === "isolated" + ? new Notification({ + title: "ADE is running in fallback mode", + body: "The ADE background service did not restart cleanly. Phone sync and ADE Code connections are unavailable while ADE keeps retrying in the background.", + }) + : new Notification({ + title: "ADE service restored", + body: "Phone sync and ADE Code connections are available again.", + }); + notification.show(); + } catch (error) { + localRuntimeLogger.warn("local_runtime.runtime_mode_notification_failed", { + mode, + error: error instanceof Error ? error.message : String(error), + }); + } + }, }); if (shouldAttemptRuntimeServiceInstall) { void localRuntimePool.installServiceBestEffort() diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index b4a443180..99b7d31f4 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -384,6 +384,52 @@ describe("local runtime connection pool", () => { }); }); + it("retries the service install from isolated recovery and reports runtime mode transitions", async () => { + const modeChanges: string[] = []; + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never, { + preferServiceRepair: true, + onRuntimeModeChange: (mode) => modeChanges.push(mode), + }); + const internals = pool as unknown as { + activeConnection: { client: unknown; child: null; socketPath: string } | null; + probeCompatibleRuntime: (socketPath: string) => Promise; + installServiceBestEffort: () => Promise; + scheduleIsolatedRuntimeRecovery: (primarySocketPath: string) => void; + tryRecoverFromIsolatedRuntime: (primarySocketPath: string) => Promise; + lastIsolatedServiceRepairMs: number; + }; + const installSpy = vi.fn(async () => {}); + internals.installServiceBestEffort = installSpy; + internals.probeCompatibleRuntime = async () => false; + internals.activeConnection = { client: {}, child: null, socketPath: "/tmp/ade-isolated.sock" }; + + internals.scheduleIsolatedRuntimeRecovery("/tmp/ade.sock"); + expect(modeChanges).toEqual(["isolated"]); + expect(pool.getStatus().runtimeMode).toBe("isolated"); + + // A failed probe re-attempts the service install once per cooldown window. + await internals.tryRecoverFromIsolatedRuntime("/tmp/ade.sock"); + expect(installSpy).toHaveBeenCalledTimes(1); + await internals.tryRecoverFromIsolatedRuntime("/tmp/ade.sock"); + expect(installSpy).toHaveBeenCalledTimes(1); + internals.lastIsolatedServiceRepairMs = 0; + await internals.tryRecoverFromIsolatedRuntime("/tmp/ade.sock"); + expect(installSpy).toHaveBeenCalledTimes(2); + + // Landing back on the primary socket announces the recovery exactly once. + internals.activeConnection = { client: {}, child: null, socketPath: "/tmp/ade.sock" }; + await internals.tryRecoverFromIsolatedRuntime("/tmp/ade.sock"); + expect(modeChanges).toEqual(["isolated", "primary"]); + expect(pool.getStatus().runtimeMode).toBe("primary"); + + pool.dispose(); + }); + it("parses structured service manager output for settings status", () => { expect(parseRuntimeServiceManagerOutput(JSON.stringify({ ok: false, diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 5b053ce9e..ab50fb7cc 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -52,6 +52,11 @@ type LocalRuntimeConnectionPoolOptions = { disableSync?: boolean; preferServiceRepair?: boolean; queryServiceStatus?: () => ServiceManagerStatusResult; + /** + * Invoked when the pool enters or leaves isolated (no-sync fallback) mode. + * "isolated" fires once per degradation, "primary" once per recovery. + */ + onRuntimeModeChange?: (mode: "primary" | "isolated") => void; }; type LocalRuntimeNodePathOptions = { @@ -609,6 +614,8 @@ export class LocalRuntimeConnectionPool { private ownedRuntimeChild: ChildProcess | null = null; private preserveOwnedRuntimeChildOnNextConnect = false; private isolatedRecoveryTimer: NodeJS.Timeout | null = null; + private isolatedModeActive = false; + private lastIsolatedServiceRepairMs = 0; private readonly coalescedActionCalls = new Map>(); private readonly projectsByRoot = new Map(); private serviceInstallStatus: LocalRuntimeStatus["serviceInstall"] = { @@ -648,6 +655,7 @@ export class LocalRuntimeConnectionPool { : this.connection ? "connecting" : "idle", + runtimeMode: this.isolatedModeActive ? "isolated" : "primary", serviceInstall: { ...this.serviceInstallStatus }, serviceHealth: { ...this.serviceHealthStatus }, }; @@ -1163,6 +1171,7 @@ export class LocalRuntimeConnectionPool { dispose(): void { this.clearIsolatedRecoveryTimer(); + this.markIsolatedMode(false, { notify: false }); const pending = this.connection; this.connection = null; this.activeConnection = null; @@ -1439,6 +1448,7 @@ export class LocalRuntimeConnectionPool { // brain is reachable; consumers recover through the normal disconnect path, // exactly as they do when a brain is recycled on a build-hash mismatch. private scheduleIsolatedRuntimeRecovery(primarySocketPath: string): void { + this.markIsolatedMode(true); if (this.isolatedRecoveryTimer) return; const timer = setInterval(() => { void this.tryRecoverFromIsolatedRuntime(primarySocketPath); @@ -1453,24 +1463,63 @@ export class LocalRuntimeConnectionPool { this.isolatedRecoveryTimer = null; } + private markIsolatedMode(active: boolean, options: { notify?: boolean } = {}): void { + if (this.isolatedModeActive === active) return; + this.isolatedModeActive = active; + if (options.notify === false) return; + try { + this.options.onRuntimeModeChange?.(active ? "isolated" : "primary"); + } catch (error) { + this.logger.warn("local_runtime.runtime_mode_listener_failed", { + mode: active ? "isolated" : "primary", + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async tryRecoverFromIsolatedRuntime(primarySocketPath: string): Promise { const entry = this.activeConnection; if (!entry || entry.socketPath === primarySocketPath) { this.clearIsolatedRecoveryTimer(); + // No active isolated connection: either we reconnected to the primary + // socket through the normal path (a real recovery worth announcing) or + // the connection is simply gone (stay quiet). + this.markIsolatedMode(false, { notify: entry != null }); + return; + } + if (!(await this.probeCompatibleRuntime(primarySocketPath))) { + // The probe alone can never succeed when the service (re)install failed: + // nothing is going to bind a compatible brain to the primary socket. Keep + // re-attempting the install (cooled down) so a transient launchctl + // failure does not strand this desktop in no-sync mode forever. + await this.retryServiceInstallForIsolatedRecovery(); return; } - if (!(await this.probeCompatibleRuntime(primarySocketPath))) return; this.logger.info("local_runtime.isolated_recovery", { primarySocketPath, isolatedSocketPath: entry.socketPath, }); this.clearIsolatedRecoveryTimer(); if (!this.clearConnectionIfCurrent(entry)) return; + this.markIsolatedMode(false); closeRuntimeClient(entry.client); if (this.ownedRuntimeChild === entry.child) this.ownedRuntimeChild = null; disposeOwnedRuntimeChild(entry.child, entry.socketPath, { unlinkSocket: true }); } + private async retryServiceInstallForIsolatedRecovery(): Promise { + if (!this.options.preferServiceRepair) return; + if (this.serviceInstallStatus.state === "skipped" || this.serviceInstallStatus.state === "installing") return; + const now = Date.now(); + if (now - this.lastIsolatedServiceRepairMs < 60_000) return; + this.lastIsolatedServiceRepairMs = now; + this.logger.info("local_runtime.isolated_recovery_service_reinstall", { + serviceState: this.serviceInstallStatus.state, + serviceMessage: this.serviceInstallStatus.message, + }); + await this.installServiceBestEffort(); + } + // Compatibility check with no side effects on pool state, safe to run while // another connection is active. private async probeCompatibleRuntime(socketPath: string): Promise { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 8b38c66b1..cf4d9bdff 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3178,6 +3178,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { env: {}, localRuntime: { connectionState: "idle", + runtimeMode: "primary", serviceInstall: { state: "skipped", attempted: false, diff --git a/apps/desktop/src/renderer/components/settings/AboutSection.tsx b/apps/desktop/src/renderer/components/settings/AboutSection.tsx index 56593674b..1bf983bf8 100644 --- a/apps/desktop/src/renderer/components/settings/AboutSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AboutSection.tsx @@ -229,7 +229,9 @@ export function AboutSection() { ADE runtime service
- Connection: {info.localRuntime.connectionState}. Install: {info.localRuntime.serviceInstall.message ?? "No install status."} + Connection: {info.localRuntime.connectionState} + {info.localRuntime.runtimeMode === "isolated" ? " (fallback brain — phone sync unavailable, retrying)" : ""} + . Install: {info.localRuntime.serviceInstall.message ?? "No install status."}
Login service: {info.localRuntime.serviceHealth.message ?? "No service health status."} diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 481c0e9d0..1e225d2cc 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -24,6 +24,13 @@ export type LocalRuntimeConnectionState = export type LocalRuntimeStatus = { connectionState: LocalRuntimeConnectionState; + /** + * "isolated" means the desktop fell back to an app-owned no-sync brain + * (phone sync and ADE Code attach to the channel service, which is down). + * The pool keeps probing and reinstalling until it migrates back to + * "primary". + */ + runtimeMode: "primary" | "isolated"; serviceInstall: { state: LocalRuntimeServiceInstallState; attempted: boolean; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 72a83e3be..d3929ac47 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -137,7 +137,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This **Bundled runtime artifacts.** Per-platform `ade-` binaries plus their native dep tarballs live under `apps/desktop/resources/runtime/`, with packaged ADE CLI resources providing the `ptyHostWorker.cjs` used by remote terminals. `release-core.yml` builds the cross-platform set; `bootstrapRemoteRuntime` uploads missing or hash-mismatched artifacts on first SSH connect from the desktop client. -**Headless install.** A standalone runtime can be installed on a headless machine without going through the desktop installer: +**Headless install.** A standalone runtime can be installed on a headless machine without going through the desktop installer — but note that releases currently publish macOS desktop assets only, so the runtime binaries + `install.sh` are not on the release page (their publish block in `release-core.yml` is commented out). Remote machines reached over SSH don't need this path: `bootstrapRemoteRuntime` uploads the desktop app's bundled runtime artifacts. When the publish block is re-enabled: ```bash curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index f047102d1..00f118c6d 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -116,7 +116,7 @@ npm --prefix apps/ade-cli run build:static -- --target --out-dir ../des ## Standalone runtime install -For headless macOS / Linux machines that can run an SSH server but have no desktop, install the runtime directly from a release: +For headless macOS / Linux machines that can run an SSH server but have no desktop, the runtime can be installed directly from a release. **Currently unavailable:** releases publish macOS desktop assets only — the runtime binaries and `install.sh` publish block in `release-core.yml` is commented out, so the command below 404s until it is re-enabled. SSH-reachable machines don't need it: the desktop bootstrap uploads bundled runtime artifacts on first connect. ```bash curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh From eb21b1ba2908cadb796b78ea7c361991b54e08d8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:53:01 -0400 Subject: [PATCH 3/3] =?UTF-8?q?ship:=20iter=201=20=E2=80=94=20address=20re?= =?UTF-8?q?view=20(phone=20filter,=20isolated-recovery=20race,=20brew-tap?= =?UTF-8?q?=20robustness)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.ts: use canonical phone convention (deviceType "phone" AND platform "iOS") for update-impact warning, matching the phone device list + APNS targeting (Greptile P1 / CodeRabbit). - localRuntimeConnectionPool: confirm the isolated connection is still current before clearing the recovery timer, so a raced connection swap can't strand the pool in "isolated" mode with no retry (CodeRabbit). - update-brew-tap.sh: portable in-place sed, repo-local git identity for the fallback commit, and a loud guard if the cask substitution silently no-ops (Greptile P2 / CodeRabbit). - update-brew-tap.yml: same silent-no-op guard after the sed bumps. - release SKILL.md: align the Phase 5 draft-asset expectation to the mac-only set. Declined: pinning GitHub's SSH host key in update-brew-tap.yml — ssh-keyscan to github.com from a GitHub-hosted runner is standard and low-risk; pinning adds a key-rotation maintenance burden that can itself silently break the auto-bump. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/release/SKILL.md | 6 +++--- .github/workflows/update-brew-tap.yml | 7 +++++++ apps/desktop/src/main/main.ts | 6 +++++- .../localRuntime/localRuntimeConnectionPool.ts | 6 +++++- scripts/update-brew-tap.sh | 18 +++++++++++++++--- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index d01e22fb3..e0970b592 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -271,9 +271,9 @@ RELEASE_SHA=$(git rev-parse origin/main) Leave `isDraft=true`. Do not publish. - Expect the draft to carry both macOS and Windows assets once `publish-release` runs: - - macOS: `ADE--universal.dmg`, `ADE--universal-mac.zip`, `ADE--universal-mac.zip.blockmap`, `latest-mac.yml` - - Windows: `ADE--win-x64.exe`, `ADE--win-x64.exe.blockmap`, `latest.yml` + Expect the draft to carry the macOS-only asset set once `publish-release` runs + (the Windows/runtime surface is currently disabled in `release-core.yml`): + - `ADE--universal.dmg`, `ADE--universal-mac.zip`, `ADE--universal-mac.zip.blockmap`, `latest-mac.yml` --- diff --git a/.github/workflows/update-brew-tap.yml b/.github/workflows/update-brew-tap.yml index 6c09ddcd3..189a8661a 100644 --- a/.github/workflows/update-brew-tap.yml +++ b/.github/workflows/update-brew-tap.yml @@ -63,6 +63,13 @@ jobs: cd tap sed -i -E "s|^ version \".*\"$| version \"$VERSION\"|" Casks/ade.rb sed -i -E "s|^ sha256 \".*\"$| sha256 \"$SHA256\"|" Casks/ade.rb + # Fail loudly if the cask shape changed and a substitution silently + # no-op'd — otherwise `git diff --quiet` reports "nothing to do" and + # the tap quietly stops tracking releases. + grep -qxF " version \"$VERSION\"" Casks/ade.rb \ + || { echo "::error::version line not updated — Casks/ade.rb format may have changed."; exit 1; } + grep -qxF " sha256 \"$SHA256\"" Casks/ade.rb \ + || { echo "::error::sha256 line not updated — Casks/ade.rb format may have changed."; exit 1; } if git diff --quiet; then echo "Cask already at ADE $VERSION — nothing to do." exit 0 diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 18d490119..fffc08362 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -5703,7 +5703,11 @@ app.whenReady().then(async () => { const phonesById = new Map(); const recordPeers = (peers: readonly SyncPeerConnectionState[] | undefined): void => { for (const peer of peers ?? []) { - if (peer.deviceType !== "phone" && peer.platform !== "iOS") continue; + // Match the canonical connected-phone convention used by the phone + // device list (main.ts ~2535) and APNS targeting: a phone is both + // deviceType "phone" AND platform "iOS". The looser OR form belongs to + // host-side sync gating, not user-facing phone copy. + if (peer.deviceType !== "phone" || peer.platform !== "iOS") continue; phonesById.set(peer.deviceId, peer.deviceName?.trim() || "Connected phone"); } }; diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index ab50fb7cc..64f8e1453 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -1499,8 +1499,12 @@ export class LocalRuntimeConnectionPool { primarySocketPath, isolatedSocketPath: entry.socketPath, }); - this.clearIsolatedRecoveryTimer(); + // Confirm the isolated connection is still current BEFORE tearing down the + // recovery timer. If a connection swap raced during the probe above, bail + // with the timer intact so the next tick re-evaluates — clearing the timer + // first would strand us in "isolated" mode with no further recovery. if (!this.clearConnectionIfCurrent(entry)) return; + this.clearIsolatedRecoveryTimer(); this.markIsolatedMode(false); closeRuntimeClient(entry.client); if (this.ownedRuntimeChild === entry.child) this.ownedRuntimeChild = null; diff --git a/scripts/update-brew-tap.sh b/scripts/update-brew-tap.sh index 51e1dad46..eb950e202 100755 --- a/scripts/update-brew-tap.sh +++ b/scripts/update-brew-tap.sh @@ -41,14 +41,26 @@ trap 'rm -rf "$tmp_dir"' EXIT gh repo clone "$tap_repo" "$tmp_dir/tap" -- -q --depth 1 cask="$tmp_dir/tap/Casks/ade.rb" -sed -i '' -E "s|^ version \".*\"$| version \"$version\"|" "$cask" -sed -i '' -E "s|^ sha256 \".*\"$| sha256 \"$sha\"|" "$cask" +# Portable in-place edit (BSD/macOS and GNU sed both accept this temp-file form). +sed -E "s|^ version \".*\"$| version \"$version\"|" "$cask" > "$cask.tmp" && mv "$cask.tmp" "$cask" +sed -E "s|^ sha256 \".*\"$| sha256 \"$sha\"|" "$cask" > "$cask.tmp" && mv "$cask.tmp" "$cask" + +# Fail loudly if the cask shape changed and the substitution silently no-op'd — +# otherwise `git diff --quiet` below would report "nothing to do" and the tap +# would stop tracking releases without any alarm. +grep -qxF " version \"$version\"" "$cask" \ + || { echo "error: version line not updated — Casks/ade.rb format may have changed." >&2; exit 1; } +grep -qxF " sha256 \"$sha\"" "$cask" \ + || { echo "error: sha256 line not updated — Casks/ade.rb format may have changed." >&2; exit 1; } if git -C "$tmp_dir/tap" diff --quiet; then echo "Cask already at ADE $version — nothing to do." exit 0 fi -git -C "$tmp_dir/tap" commit -aqm "ade $version" +git -C "$tmp_dir/tap" \ + -c user.name="ade-release-bot" \ + -c user.email="release-bot@users.noreply.github.com" \ + commit -aqm "ade $version" git -C "$tmp_dir/tap" push -q echo "Updated $tap_repo cask to ADE $version (sha256 $sha)."