From a08f9a00d0175484a6ae07ac28efd6b04531ee46 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:44:27 +0000 Subject: [PATCH 1/6] Add mac universal build & signing CI Add a macOS universal build and signing flow to CI and the repo: introduce a build-mac-app matrix job that produces arm64 and x64 unsigned app bundles, archive/upload them, then merge into a universal app and run signing/notarization/publish during release. Add helper scripts (make-universal-mac.mjs, require-macos-release-secrets.cjs), entitlements plist files, and package.json scripts for per-arch dist, merging, and signed/universal releases; enable hardened runtime, entitlements, and notarization in electron-builder config. Update CONTRIBUTING.md and README with Apple signing/notarization instructions and the required GitHub secrets. Miscellaneous supporting changes across desktop code, tests, docs, and CTO identity metadata. --- .ade/cto/identity.yaml | 16 +- .github/workflows/release.yml | 100 +- CONTRIBUTING.md | 56 + README.md | 20 +- .../build/entitlements.mac.inherit.plist | 12 + apps/desktop/build/entitlements.mac.plist | 10 + apps/desktop/package-lock.json | 267 +- apps/desktop/package.json | 24 +- apps/desktop/scripts/make-universal-mac.mjs | 64 + .../scripts/require-macos-release-secrets.cjs | 71 + apps/desktop/src/main/main.ts | 47 +- .../services/ai/claudeRuntimeProbe.test.ts | 42 + .../main/services/ai/claudeRuntimeProbe.ts | 7 + .../ai/providerConnectionStatus.test.ts | 127 + .../services/ai/providerConnectionStatus.ts | 135 +- .../ai/tools/ctoOperatorTools.test.ts | 45 +- .../services/ai/tools/ctoOperatorTools.ts | 563 +++- .../main/services/chat/agentChatService.ts | 380 ++- .../main/services/cto/ctoStateService.test.ts | 8 +- .../src/main/services/cto/ctoStateService.ts | 105 +- .../src/main/services/lanes/laneService.ts | 86 +- .../memory/hybridSearchService.test.ts | 38 +- .../services/memory/hybridSearchService.ts | 6 +- .../orchestrator/orchestratorService.ts | 11 +- .../src/main/services/prs/prService.ts | 73 +- .../main/services/state/crsqliteExtension.ts | 8 +- .../src/main/services/state/kvDb.sync.test.ts | 28 +- .../src/main/services/state/kvDb.test.ts | 250 ++ apps/desktop/src/main/services/state/kvDb.ts | 177 +- .../services/state/onConflictAudit.test.ts | 10 + .../services/sync/deviceRegistryService.ts | 60 +- .../services/sync/syncHostService.test.ts | 310 ++- .../src/main/services/sync/syncHostService.ts | 133 +- .../services/sync/syncRemoteCommandService.ts | 1032 ++++++- .../main/services/sync/syncService.test.ts | 82 + .../src/main/services/sync/syncService.ts | 108 +- .../src/renderer/components/app/TopBar.tsx | 41 +- .../chat/AgentChatMessageList.test.tsx | 87 + .../components/chat/AgentChatMessageList.tsx | 144 +- .../chat/ChatSubagentStrip.test.tsx | 56 + .../components/chat/ChatSubagentStrip.tsx | 8 +- .../chat/chatExecutionSummary.test.ts | 49 + .../components/chat/chatExecutionSummary.ts | 18 + .../renderer/components/cto/CtoPage.test.tsx | 2 +- .../src/renderer/components/cto/CtoPage.tsx | 16 +- .../components/cto/CtoPromptPreview.tsx | 3 +- .../components/cto/OnboardingBanner.tsx | 2 +- .../components/cto/OnboardingWizard.tsx | 844 ++---- .../components/missions/MissionsPage.tsx | 14 + .../settings/ProvidersSection.test.tsx | 122 + .../components/settings/ProvidersSection.tsx | 80 +- .../settings/SyncDevicesSection.tsx | 655 +++-- apps/desktop/src/shared/types/chat.ts | 32 + apps/desktop/src/shared/types/cto.ts | 2 +- apps/desktop/src/shared/types/lanes.ts | 60 + apps/desktop/src/shared/types/sync.ts | 107 +- apps/ios/ADE.xcodeproj/project.pbxproj | 13 +- apps/ios/ADE/App/ADEApp.swift | 52 + apps/ios/ADE/App/ContentView.swift | 723 ++++- .../BrandMark.imageset/Contents.json | 21 + apps/ios/ADE/Info.plist | 8 + apps/ios/ADE/Models/RemoteModels.swift | 480 +++- apps/ios/ADE/Services/Database.swift | 1196 ++++++++- apps/ios/ADE/Services/SyncService.swift | 1122 +++++++- apps/ios/ADE/Views/FilesTabView.swift | 270 +- apps/ios/ADE/Views/LanesTabView.swift | 2368 ++++++++++++++++- apps/ios/ADE/Views/PRsTabView.swift | 230 +- apps/ios/ADE/Views/WorkTabView.swift | 169 +- apps/ios/ADETests/ADETests.swift | 1035 +++++++ apps/mcp-server/src/bootstrap.ts | 104 +- apps/mcp-server/src/mcpServer.test.ts | 241 ++ apps/mcp-server/src/mcpServer.ts | 573 +++- apps/web/src/app/pages/DownloadPage.tsx | 4 +- apps/web/src/app/pages/HomePage.tsx | 40 +- docs/architecture/AI_INTEGRATION.md | 10 +- docs/architecture/IOS_APP.md | 50 +- docs/architecture/MULTI_DEVICE_SYNC.md | 6 +- docs/architecture/UI_FRAMEWORK.md | 2 +- docs/features/CTO.md | 15 +- docs/features/LANES.md | 2 +- docs/final-plan/README.md | 27 +- docs/final-plan/appendix.md | 4 +- docs/final-plan/phase-6.md | 97 +- docs/final-plan/phase-7.md | 16 +- docs/reference/symphony_orchestrator.ex | 1655 ------------ plan.md | 150 -- 86 files changed, 13734 insertions(+), 3802 deletions(-) create mode 100644 apps/desktop/build/entitlements.mac.inherit.plist create mode 100644 apps/desktop/build/entitlements.mac.plist create mode 100644 apps/desktop/scripts/make-universal-mac.mjs create mode 100644 apps/desktop/scripts/require-macos-release-secrets.cjs create mode 100644 apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts create mode 100644 apps/desktop/src/main/services/state/kvDb.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatSubagentStrip.test.tsx create mode 100644 apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx create mode 100644 apps/ios/ADE/Assets.xcassets/BrandMark.imageset/Contents.json delete mode 100644 docs/reference/symphony_orchestrator.ex delete mode 100644 plan.md diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index 6ee811b8..3c0feac2 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -1,13 +1,6 @@ name: CTO -version: 2 -persona: >- - You are the CTO for this project inside ADE. - - You are the persistent technical lead who owns architecture, execution - quality, engineering continuity, and team direction. - - Use ADE's tools and project context to help the team move forward with clear, - concrete decisions. +version: 3 +persona: Persistent project CTO with strategic personality. personality: strategic modelPreferences: provider: claude @@ -30,5 +23,6 @@ openclawContextPolicy: - system_prompt onboardingState: completedSteps: - - integrations -updatedAt: 2026-03-16T04:16:18.954Z + - identity + completedAt: 2026-03-17T20:35:20.336Z +updatedAt: 2026-03-17T20:35:20.336Z diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b93017a6..bc0f0f59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,8 +21,58 @@ jobs: git fetch origin main --depth=1 git merge-base --is-ancestor "$GITHUB_SHA" origin/main - release: + build-mac-app: needs: verify + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runner: macos-latest + app_dir: mac-arm64 + - arch: x64 + runner: macos-15-intel + app_dir: mac + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/desktop/package-lock.json + + - name: Install desktop dependencies + run: cd apps/desktop && npm ci + + - name: Stamp release version + env: + ADE_RELEASE_TAG: ${{ github.ref_name }} + run: cd apps/desktop && npm run version:release + + - name: Build unsigned macOS app bundle + run: cd apps/desktop && npm run dist:mac:dir -- --${{ matrix.arch }} + + - name: Archive app bundle + run: | + APP_PATH="apps/desktop/release/${{ matrix.app_dir }}/ADE.app" + ARCHIVE_PATH="apps/desktop/release/ADE-${{ matrix.arch }}.app.zip" + test -d "$APP_PATH" + ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH" + + - uses: actions/upload-artifact@v4 + with: + name: mac-app-${{ matrix.arch }} + path: apps/desktop/release/ADE-${{ matrix.arch }}.app.zip + if-no-files-found: error + + release: + needs: + - verify + - build-mac-app runs-on: macos-latest concurrency: group: release-${{ github.ref_name }} @@ -36,21 +86,51 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: | - apps/desktop/package-lock.json - apps/mcp-server/package-lock.json + cache-dependency-path: apps/desktop/package-lock.json - name: Install desktop dependencies run: cd apps/desktop && npm ci - - name: Install MCP server dependencies - run: cd apps/mcp-server && npm ci - - name: Stamp release version env: ADE_RELEASE_TAG: ${{ github.ref_name }} run: cd apps/desktop && npm run version:release + - uses: actions/download-artifact@v4 + with: + name: mac-app-arm64 + path: apps/desktop/release/_ci/downloads/arm64 + + - uses: actions/download-artifact@v4 + with: + name: mac-app-x64 + path: apps/desktop/release/_ci/downloads/x64 + + - name: Expand architecture app bundles + run: | + rm -rf apps/desktop/release/_ci/arm64 apps/desktop/release/_ci/x64 + mkdir -p apps/desktop/release/_ci/arm64 apps/desktop/release/_ci/x64 + ditto -x -k apps/desktop/release/_ci/downloads/arm64/ADE-arm64.app.zip apps/desktop/release/_ci/arm64 + ditto -x -k apps/desktop/release/_ci/downloads/x64/ADE-x64.app.zip apps/desktop/release/_ci/x64 + + - name: Merge universal app bundle + run: cd apps/desktop && npm run merge:mac:universal + + - name: Materialize App Store Connect API key + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + run: | + if [ -z "$APPLE_API_KEY_P8" ] || [ -z "$APPLE_API_KEY_ID" ]; then + echo "::error::Missing APPLE_API_KEY_P8 or APPLE_API_KEY_ID GitHub secret." + exit 1 + fi + + KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY_P8" > "$KEY_PATH" + chmod 600 "$KEY_PATH" + echo "APPLE_API_KEY=$KEY_PATH" >> "$GITHUB_ENV" + - name: Create draft GitHub release with generated notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -64,4 +144,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ELECTRON_CACHE: ${{ runner.temp }}/electron ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder - run: cd apps/desktop && npm run release:mac + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: cd apps/desktop && npm run release:mac:universal diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b84883c..d7b080a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,62 @@ npm run dev - TypeScript strict mode is enabled - Tests use Vitest +## Signed macOS releases + +For ADE's current release path, the correct Apple objects are: + +- `Developer ID Application` certificate for signing the `.app` +- App Store Connect `Team Key` for notarization + +You do not need these for the current ADE flow: + +- `Developer ID Installer` certificate, because ADE ships `dmg` + `zip`, not `pkg` +- A provisioning profile, unless the app later adds Apple advanced capabilities that require a Developer ID provisioning profile + +The tagged macOS release workflow expects these GitHub Actions secrets: + +- `CSC_LINK` — Developer ID Application certificate (`.p12`), typically base64-encoded +- `CSC_KEY_PASSWORD` — password for the Developer ID Application certificate +- `APPLE_API_KEY_P8` — raw contents of the App Store Connect Team API key (`AuthKey_*.p8`) +- `APPLE_API_KEY_ID` — App Store Connect key ID +- `APPLE_API_ISSUER` — App Store Connect issuer ID + +The release workflow builds ADE in three stages: + +1. `arm64` app bundle on `macos-latest` +2. `x64` app bundle on `macos-15-intel` +3. universal app merge, then signing, notarization, `dmg`/`zip` packaging, and GitHub release publish from the merged app + +Current Apple setup flow: + +1. On a Mac, create a CSR in Keychain Access using `Certificate Assistant > Request a Certificate from a Certificate Authority`, and save it to disk. +2. In Apple Developer > Certificates, Identifiers & Profiles > Certificates, click `+`. +3. Under `Software`, choose `Developer ID`, then choose `Developer ID Application`. +4. Upload the CSR, download the `.cer`, and double-click it so it appears in Keychain Access under `login > My Certificates`. +5. Export that certificate from Keychain Access as a `.p12` file with a password. This is the certificate material used by `CSC_LINK`. +6. In App Store Connect > Users and Access > Integrations > Team Keys, generate a Team API key and download the `.p8` file. Note the key ID and issuer ID. + +To test a signed macOS build locally, export the matching environment variables expected by `electron-builder` and run: + +```bash +cd apps/desktop +export CSC_LINK=/absolute/path/to/DeveloperIDApplication.p12 +export CSC_KEY_PASSWORD=... +export APPLE_API_KEY=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 +export APPLE_API_KEY_ID=XXXXXXXXXX +export APPLE_API_ISSUER=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +npm run dist:mac:signed +``` + +To test the unsigned intermediate app bundle that the CI workflow produces per architecture, run: + +```bash +cd apps/desktop +npm run dist:mac:dir -- --arm64 +``` + +The tagged release workflow should be run from a tag that points at `main`. Push the release tag only after the intended `main` commit is in place. + ## Code Style - TypeScript with strict mode diff --git a/README.md b/README.md index 3a180799..977508dd 100644 --- a/README.md +++ b/README.md @@ -63,28 +63,18 @@ ADE is built for people who want agents to operate inside a real development wor 1. Download the latest `.dmg` from [**Releases**](https://github.com/arul28/ADE/releases) 2. Open the `.dmg` and drag **ADE** into your Applications folder -3. Move **ADE** into your Applications folder before trying to launch it -4. Clear macOS quarantine for the installed app bundle: - -```bash -xattr -dr com.apple.quarantine /Applications/ADE.app -``` - -5. Launch ADE, open a project, and configure your AI provider in Settings +3. Launch ADE from Applications +4. Open a project and configure your AI provider in Settings ## Early beta notes ADE is still a very early beta. Expect rough edges, incomplete workflows, and occasional breaking changes between releases. -The macOS app is not code signed or notarized yet, so Gatekeeper may block it on first launch. Install the app by dragging it into `Applications` first, then if macOS still refuses to open it, run: - -```bash -xattr -dr com.apple.quarantine /Applications/ADE.app -``` +Official macOS releases are intended to ship as Developer ID-signed and notarized builds so Gatekeeper accepts them normally and ADE can apply future in-app updates without the quarantine workaround. -That removes the quarantine attribute Apple adds to downloaded apps and allows ADE to launch locally. Only do this for builds you downloaded from the ADE releases page and trust. +Older pre-signing beta builds may still need the manual `xattr -dr com.apple.quarantine /Applications/ADE.app` step if you are testing an older release artifact. -ADE auto-updates. When a new version is available, an update button appears in the header bar -- click it to restart and apply. +ADE auto-updates. When a new version is available, an update button appears in the header bar. Click it to restart and apply the signed update. ## Contributing diff --git a/apps/desktop/build/entitlements.mac.inherit.plist b/apps/desktop/build/entitlements.mac.inherit.plist new file mode 100644 index 00000000..393574a3 --- /dev/null +++ b/apps/desktop/build/entitlements.mac.inherit.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + com.apple.security.inherit + + + diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist new file mode 100644 index 00000000..896ab565 --- /dev/null +++ b/apps/desktop/build/entitlements.mac.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + + diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 95b6388d..c4274a41 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -26,6 +26,7 @@ "ai": "^6.0.94", "ai-sdk-provider-claude-code": "^3.4.2", "ai-sdk-provider-codex-cli": "^1.1.0", + "bonjour-service": "^1.3.0", "chokidar": "^4.0.3", "clsx": "^2.1.1", "dagre": "^0.8.5", @@ -38,6 +39,7 @@ "node-cron": "^3.0.3", "node-pty": "^1.1.0", "onnxruntime-node": "^1.24.3", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -53,6 +55,7 @@ }, "devDependencies": { "@electron/rebuild": "^4.0.1", + "@electron/universal": "^2.0.3", "@tailwindcss/postcss": "^4.1.18", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", @@ -60,6 +63,7 @@ "@types/dagre": "^0.7.53", "@types/node": "^20.11.30", "@types/node-cron": "^3.0.11", + "@types/qrcode": "^1.5.6", "@types/react": "^18.2.74", "@types/react-dom": "^18.2.24", "@types/sql.js": "^1.4.9", @@ -2349,6 +2353,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -4565,6 +4575,16 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -5345,7 +5365,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5355,7 +5374,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5854,6 +5872,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -6208,6 +6236,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001779", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", @@ -6495,7 +6532,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6508,7 +6544,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6894,6 +6929,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -7092,6 +7136,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -7248,6 +7298,18 @@ "license": "MIT", "optional": true }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -7631,7 +7693,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -8633,7 +8694,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -9321,7 +9381,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11452,6 +11511,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -11941,6 +12013,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -12012,7 +12093,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12191,6 +12271,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -12472,6 +12561,141 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -12878,7 +13102,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12893,6 +13116,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -13277,6 +13506,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -13645,7 +13880,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13690,7 +13924,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -14015,6 +14248,12 @@ "node": ">=0.8" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -15439,6 +15678,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d58c921b..85c4f069 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,7 +11,12 @@ "dev": "node ./scripts/ensure-electron.cjs && node ./scripts/dev.cjs", "build": "tsup && vite build", "dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac --publish never", - "release:mac": "npm run build && npm run rebuild:native && electron-builder --mac --publish always", + "dist:mac:dir": "npm run build && npm run rebuild:native && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", + "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run build && npm run rebuild:native && electron-builder --mac --publish never", + "merge:mac:universal": "node ./scripts/make-universal-mac.mjs", + "dist:mac:universal:signed": "node ./scripts/require-macos-release-secrets.cjs && electron-builder --mac --universal --publish never --prepackaged release/mac-universal/ADE.app", + "release:mac": "node ./scripts/require-macos-release-secrets.cjs && npm run build && npm run rebuild:native && electron-builder --mac --publish always", + "release:mac:universal": "node ./scripts/require-macos-release-secrets.cjs && electron-builder --mac --universal --publish always --prepackaged release/mac-universal/ADE.app", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:orchestrator-smoke": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts --reporter=verbose", @@ -44,6 +49,7 @@ "ai": "^6.0.94", "ai-sdk-provider-claude-code": "^3.4.2", "ai-sdk-provider-codex-cli": "^1.1.0", + "bonjour-service": "^1.3.0", "chokidar": "^4.0.3", "clsx": "^2.1.1", "dagre": "^0.8.5", @@ -56,6 +62,7 @@ "node-cron": "^3.0.3", "node-pty": "^1.1.0", "onnxruntime-node": "^1.24.3", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -70,6 +77,8 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@electron/universal": "^2.0.3", + "@electron/rebuild": "^4.0.1", "@tailwindcss/postcss": "^4.1.18", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", @@ -77,19 +86,19 @@ "@types/dagre": "^0.7.53", "@types/node": "^20.11.30", "@types/node-cron": "^3.0.11", + "@types/qrcode": "^1.5.6", "@types/react": "^18.2.74", "@types/react-dom": "^18.2.24", "@types/sql.js": "^1.4.9", "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.24", "concurrently": "^9.1.0", "cross-env": "^7.0.3", "electron": "^40.2.1", - "@electron/rebuild": "^4.0.1", "electron-builder": "^26.8.1", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "jsdom": "^22.1.0", @@ -129,7 +138,12 @@ "zip" ], "icon": "build/icon.icns", - "category": "public.app-category.developer-tools" + "category": "public.app-category.developer-tools", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.inherit.plist", + "notarize": true } } } diff --git a/apps/desktop/scripts/make-universal-mac.mjs b/apps/desktop/scripts/make-universal-mac.mjs new file mode 100644 index 00000000..dd9d3316 --- /dev/null +++ b/apps/desktop/scripts/make-universal-mac.mjs @@ -0,0 +1,64 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { makeUniversalApp } from "@electron/universal"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const appDir = path.resolve(scriptDir, ".."); + +function readFlag(name) { + const prefix = `${name}=`; + for (const arg of process.argv.slice(2)) { + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + } + return null; +} + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +function resolveAbsolute(input, fallback) { + const value = input?.trim() || fallback; + if (!value) { + throw new Error("Missing required path argument"); + } + return path.isAbsolute(value) ? value : path.resolve(appDir, value); +} + +const x64AppPath = resolveAbsolute( + readFlag("--x64-app") ?? process.env.ADE_X64_APP_PATH, + path.join(appDir, "release", "_ci", "x64", "ADE.app"), +); +const arm64AppPath = resolveAbsolute( + readFlag("--arm64-app") ?? process.env.ADE_ARM64_APP_PATH, + path.join(appDir, "release", "_ci", "arm64", "ADE.app"), +); +const outAppPath = resolveAbsolute( + readFlag("--out-app") ?? process.env.ADE_UNIVERSAL_APP_PATH, + path.join(appDir, "release", "mac-universal", "ADE.app"), +); + +const mergeAsars = hasFlag("--merge-asars") || process.env.ADE_MERGE_ASARS === "1"; +const singleArchFiles = readFlag("--single-arch-files") ?? process.env.ADE_SINGLE_ARCH_FILES ?? undefined; +const x64ArchFiles = readFlag("--x64-arch-files") ?? process.env.ADE_X64_ARCH_FILES ?? undefined; + +await fs.access(x64AppPath); +await fs.access(arm64AppPath); +await fs.rm(outAppPath, { recursive: true, force: true }); +await fs.mkdir(path.dirname(outAppPath), { recursive: true }); + +await makeUniversalApp({ + x64AppPath, + arm64AppPath, + outAppPath, + force: true, + mergeASARs: mergeAsars, + singleArchFiles, + x64ArchFiles, +}); + +console.log( + `[release:mac] Created universal app bundle at ${outAppPath} ` + + `(mergeASARs=${mergeAsars ? "true" : "false"}).`, +); diff --git a/apps/desktop/scripts/require-macos-release-secrets.cjs b/apps/desktop/scripts/require-macos-release-secrets.cjs new file mode 100644 index 00000000..f5aaceb6 --- /dev/null +++ b/apps/desktop/scripts/require-macos-release-secrets.cjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +"use strict"; + +function hasEnv(name) { + return Boolean(process.env[name] && String(process.env[name]).trim().length > 0); +} + +function formatList(values) { + return values.map((value) => `- ${value}`).join("\n"); +} + +const missing = []; + +for (const name of ["CSC_LINK", "CSC_KEY_PASSWORD"]) { + if (!hasEnv(name)) { + missing.push(name); + } +} + +const notarizationProfiles = [ + { + label: "App Store Connect API key", + vars: ["APPLE_API_KEY", "APPLE_API_KEY_ID", "APPLE_API_ISSUER"], + }, + { + label: "Apple ID app-specific password", + vars: ["APPLE_ID", "APPLE_APP_SPECIFIC_PASSWORD", "APPLE_TEAM_ID"], + }, + { + label: "notarytool keychain profile", + vars: ["APPLE_KEYCHAIN_PROFILE"], + }, +]; + +const matchingProfile = notarizationProfiles.find((profile) => profile.vars.every(hasEnv)); + +if (!matchingProfile) { + const providedAppleVars = notarizationProfiles.flatMap((profile) => profile.vars).filter(hasEnv); + process.stderr.write( + "[release:mac] Missing notarization credentials.\n" + + "Provide one complete credential set before building a signed macOS release:\n" + + formatList( + notarizationProfiles.map((profile) => `${profile.label}: ${profile.vars.join(", ")}`) + ) + + "\n" + ); + if (providedAppleVars.length > 0) { + process.stderr.write( + `[release:mac] Partial Apple credential environment detected: ${providedAppleVars.join(", ")}\n` + ); + } + process.exit(1); +} + +if (missing.length > 0) { + process.stderr.write( + "[release:mac] Missing required release environment variables:\n" + formatList(missing) + "\n" + ); + process.exit(1); +} + +if (matchingProfile.vars.includes("APPLE_API_KEY") && !String(process.env.APPLE_API_KEY).startsWith("/")) { + process.stderr.write( + "[release:mac] APPLE_API_KEY must point to the absolute path of the App Store Connect .p8 key file.\n" + ); + process.exit(1); +} + +process.stdout.write( + `[release:mac] macOS signing and notarization environment looks complete (${matchingProfile.label}).\n` +); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 33558962..3247ff74 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -881,6 +881,16 @@ app.whenReady().then(async () => { } }); + const processService = createProcessService({ + db, + projectId, + processLogsDir: adePaths.processLogsDir, + logger, + laneService, + projectConfigService, + broadcastEvent: (ev) => emitProjectEvent(projectRoot, IPC.processesEvent, ev) + }); + const onTrackedSessionEnded = ({ laneId, sessionId, exitCode }: { laneId: string; sessionId: string; exitCode: number | null }) => { jobEngine?.onSessionEnded({ laneId, sessionId }); automationService?.onSessionEnded({ laneId, sessionId }); @@ -1173,6 +1183,7 @@ app.whenReady().then(async () => { projectId, memoryService, packService, + fileService, workerAgentService, workerHeartbeatService, linearIssueTracker, @@ -1183,6 +1194,7 @@ app.whenReady().then(async () => { linearClient, linearCredentials: linearCredentialService, prService, + processService, episodicSummaryService, laneService, sessionService, @@ -1249,16 +1261,6 @@ app.whenReady().then(async () => { onHeadChanged: handleHeadChanged }); - const processService = createProcessService({ - db, - projectId, - processLogsDir: adePaths.processLogsDir, - logger, - laneService, - projectConfigService, - broadcastEvent: (ev) => emitProjectEvent(projectRoot, IPC.processesEvent, ev) - }); - const testService = createTestService({ db, projectId, @@ -1572,9 +1574,18 @@ app.whenReady().then(async () => { projectRoot, fileService, laneService, + gitService, + diffService, + conflictService, prService, sessionService, ptyService, + projectConfigService, + portAllocationService, + laneEnvironmentService, + laneTemplateService, + rebaseSuggestionService, + autoRebaseService, computerUseArtifactBrokerService, missionService, agentChatService, @@ -2026,10 +2037,19 @@ app.whenReady().then(async () => { missionService, ptyService, testService, + agentChatService, prService, + fileService, memoryService, ctoStateService, workerAgentService, + flowPolicyService, + linearDispatcherService, + linearIssueTracker, + linearSyncService, + linearIngressService, + linearRoutingService, + processService, externalMcpService, computerUseArtifactBrokerService, orchestratorService, @@ -2480,17 +2500,16 @@ app.whenReady().then(async () => { dormantContext = createDormantProjectContext(); + const FILE_LIMIT_CODES = new Set(["EMFILE", "ENFILE"]); + let emfileWarned = false; process.on("uncaughtException", (err) => { - // Suppress repeated EMFILE errors to avoid log flooding - if ((err as NodeJS.ErrnoException).code === "EMFILE" || (err as NodeJS.ErrnoException).code === "ENFILE") return; + if (FILE_LIMIT_CODES.has((err as NodeJS.ErrnoException).code ?? "")) return; getActiveContext().logger.error("process.uncaught_exception", { err: String(err), stack: err instanceof Error ? err.stack : undefined }); }); - let emfileWarned = false; process.on("unhandledRejection", (reason) => { - // Suppress repeated EMFILE errors to avoid log flooding const msg = String(reason); if (msg.includes("EMFILE") || msg.includes("ENFILE")) { if (!emfileWarned) { diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts index ff6fa746..78406d1e 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts @@ -19,6 +19,7 @@ vi.mock("./providerRuntimeHealth", () => ({ let probeClaudeRuntimeHealth: typeof import("./claudeRuntimeProbe").probeClaudeRuntimeHealth; let resetClaudeRuntimeProbeCache: typeof import("./claudeRuntimeProbe").resetClaudeRuntimeProbeCache; +let isClaudeRuntimeAuthError: typeof import("./claudeRuntimeProbe").isClaudeRuntimeAuthError; function makeStream(messages: unknown[]) { const close = vi.fn(); @@ -42,6 +43,7 @@ beforeEach(async () => { const mod = await import("./claudeRuntimeProbe"); probeClaudeRuntimeHealth = mod.probeClaudeRuntimeHealth; resetClaudeRuntimeProbeCache = mod.resetClaudeRuntimeProbeCache; + isClaudeRuntimeAuthError = mod.isClaudeRuntimeAuthError; resetClaudeRuntimeProbeCache(); }); @@ -65,4 +67,44 @@ describe("claudeRuntimeProbe", () => { expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1); expect(mockState.reportProviderRuntimeFailure).not.toHaveBeenCalled(); }); + + it("treats Anthropic 401 invalid credentials responses as auth failures", async () => { + expect( + isClaudeRuntimeAuthError( + 'API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}', + ), + ).toBe(true); + + const query = makeStream([ + { + type: "result", + subtype: "success", + duration_ms: 12, + duration_api_ms: 12, + is_error: true, + num_turns: 1, + result: "", + session_id: "session-401", + total_cost_usd: 0, + usage: { + input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens: 0, + server_tool_use: { web_search_requests: 0 }, + service_tier: "standard", + }, + errors: [ + 'API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}', + ], + }, + ]); + mockState.query.mockReturnValue(query.stream); + + await probeClaudeRuntimeHealth({ projectRoot: "/tmp/project", force: true }); + + expect(query.close).toHaveBeenCalledTimes(1); + expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1); + expect(mockState.reportProviderRuntimeFailure).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index 883d3f7e..f5c73c3b 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -34,11 +34,18 @@ export function isClaudeRuntimeAuthError(input: unknown): boolean { lower.includes("not authenticated") || lower.includes("not logged in") || lower.includes("authentication required") + || lower.includes("authentication error") + || lower.includes("authentication_error") || lower.includes("login required") || lower.includes("sign in") || lower.includes("claude auth login") || lower.includes("/login") || lower.includes("authentication_failed") + || lower.includes("invalid authentication credentials") + || lower.includes("invalid api key") + || lower.includes("api error: 401") + || lower.includes("status code: 401") + || lower.includes("status 401") ); } diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts new file mode 100644 index 00000000..a1d595c6 --- /dev/null +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AiProviderConnections } from "../../../shared/types"; +import type { CliAuthStatus } from "./authDetector"; + +const mockState = vi.hoisted(() => ({ + readClaudeCredentials: vi.fn(), + readCodexCredentials: vi.fn(), + isCodexTokenStale: vi.fn(), + getProviderRuntimeHealth: vi.fn(), +})); + +vi.mock("./providerCredentialSources", () => ({ + readClaudeCredentials: (...args: unknown[]) => mockState.readClaudeCredentials(...args), + readCodexCredentials: (...args: unknown[]) => mockState.readCodexCredentials(...args), + isCodexTokenStale: (...args: unknown[]) => mockState.isCodexTokenStale(...args), +})); + +vi.mock("./providerRuntimeHealth", () => ({ + getProviderRuntimeHealth: (...args: unknown[]) => mockState.getProviderRuntimeHealth(...args), +})); + +let buildProviderConnections: (cliStatuses: CliAuthStatus[]) => Promise; + +beforeEach(async () => { + vi.resetModules(); + mockState.readClaudeCredentials.mockReset(); + mockState.readCodexCredentials.mockReset(); + mockState.isCodexTokenStale.mockReset(); + mockState.getProviderRuntimeHealth.mockReset(); + + mockState.readClaudeCredentials.mockResolvedValue(null); + mockState.readCodexCredentials.mockResolvedValue(null); + mockState.isCodexTokenStale.mockReturnValue(false); + mockState.getProviderRuntimeHealth.mockReturnValue(null); + + ({ buildProviderConnections } = await import("./providerConnectionStatus")); +}); + +describe("buildProviderConnections", () => { + it("does not mark Claude runtime as connected when the CLI explicitly reports signed out", async () => { + mockState.readClaudeCredentials.mockResolvedValue({ + accessToken: "token", + source: "claude-credentials-file", + }); + + const result = await buildProviderConnections([ + { + cli: "claude", + installed: true, + path: "/Users/arul/.local/bin/claude", + authenticated: false, + verified: true, + }, + { + cli: "codex", + installed: false, + path: null, + authenticated: false, + verified: false, + }, + ]); + + expect(result.claude.authAvailable).toBe(true); + expect(result.claude.runtimeDetected).toBe(true); + expect(result.claude.runtimeAvailable).toBe(false); + expect(result.claude.blocker).toContain("Claude CLI reports no active login"); + expect(result.claude.blocker).toContain("claude auth login"); + }); + + it("keeps the optimistic local-credentials fallback when CLI auth could not be verified", async () => { + mockState.readClaudeCredentials.mockResolvedValue({ + accessToken: "token", + source: "claude-credentials-file", + }); + + const result = await buildProviderConnections([ + { + cli: "claude", + installed: true, + path: "/Users/arul/.local/bin/claude", + authenticated: false, + verified: false, + }, + { + cli: "codex", + installed: false, + path: null, + authenticated: false, + verified: false, + }, + ]); + + expect(result.claude.authAvailable).toBe(true); + expect(result.claude.runtimeAvailable).toBe(true); + expect(result.claude.blocker).toBeNull(); + }); + + it("applies the same signed-out guard to Codex when local auth artifacts remain on disk", async () => { + mockState.readCodexCredentials.mockResolvedValue({ + accessToken: "token", + source: "codex-auth-file", + }); + + const result = await buildProviderConnections([ + { + cli: "claude", + installed: false, + path: null, + authenticated: false, + verified: false, + }, + { + cli: "codex", + installed: true, + path: "/Users/arul/.local/bin/codex", + authenticated: false, + verified: true, + }, + ]); + + expect(result.codex.authAvailable).toBe(true); + expect(result.codex.runtimeDetected).toBe(true); + expect(result.codex.runtimeAvailable).toBe(false); + expect(result.codex.blocker).toContain("Codex CLI reports no active login"); + expect(result.codex.blocker).toContain("codex login"); + }); +}); diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 028a87ba..bd90edb4 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -38,23 +38,72 @@ export async function buildProviderConnections( const claudeRuntimeHealth = getProviderRuntimeHealth("claude"); const codexRuntimeHealth = getProviderRuntimeHealth("codex"); - const claudeRuntimeDetected = Boolean(claudeCli?.installed); - const claudeCliAuthenticated = Boolean(claudeCli?.installed && claudeCli.authenticated); - const claudeAuthAvailable = Boolean(claudeLocalCreds || claudeCliAuthenticated); - // Connected = we have auth credentials + the CLI is installed. - // The runtime probe can only DOWNGRADE this to false on explicit auth failure. - const claudeRuntimeAvailable = Boolean(claudeAuthAvailable && claudeRuntimeDetected); + const deriveProviderFlags = ( + cli: CliAuthStatus | null, + localCreds: Awaited> | Awaited>, + ) => { + const runtimeDetected = Boolean(cli?.installed); + const cliAuthenticated = Boolean(cli?.installed && cli.authenticated); + const cliExplicitlyUnauthenticated = Boolean(cli?.installed && cli.verified && !cli.authenticated); + const localCredsDetected = Boolean(localCreds); + const authAvailable = Boolean(localCreds || cliAuthenticated); + // Local credential artifacts are only a fallback signal. If the CLI itself + // has already verified that the user is signed out, do not promote the + // provider to runtime-ready until the user logs in again. + const runtimeAvailable = Boolean(authAvailable && runtimeDetected && !cliExplicitlyUnauthenticated); + return { runtimeDetected, cliAuthenticated, cliExplicitlyUnauthenticated, localCredsDetected, authAvailable, runtimeAvailable }; + }; - const codexRuntimeDetected = Boolean(codexCli?.installed); - const codexCliAuthenticated = Boolean(codexCli?.installed && codexCli.authenticated); + const claudeFlags = deriveProviderFlags(claudeCli, claudeLocalCreds); + const codexFlags = deriveProviderFlags(codexCli, codexLocalCreds); const codexUsageAvailable = Boolean(codexLocalCreds && !isCodexTokenStale(codexLocalCreds)); - const codexAuthAvailable = Boolean(codexLocalCreds || codexCliAuthenticated); - const codexRuntimeAvailable = Boolean(codexAuthAvailable && codexRuntimeDetected); + + function resolveBlocker( + providerLabel: string, + loginHint: string, + flags: ReturnType, + extraBlocker?: string | null, + ): string | null { + if (!flags.authAvailable && !flags.runtimeDetected) { + return `No ${providerLabel} authentication or CLI was found locally.`; + } + if (flags.cliExplicitlyUnauthenticated) { + return flags.localCredsDetected + ? `Local ${providerLabel} credentials were found, but ${providerLabel} CLI reports no active login. Run: ${loginHint}` + : `${providerLabel} CLI is installed but no login was detected. Run: ${loginHint}`; + } + if (!flags.authAvailable) { + return `${providerLabel} CLI is installed but no login was detected. Run: ${loginHint}`; + } + if (!flags.runtimeDetected) { + return `Local credentials exist but the ${providerLabel} CLI is not on ADE's PATH.`; + } + if (extraBlocker) return extraBlocker; + return null; + } + + // Apply runtime health overrides. + // Only an explicit auth failure should downgrade status. Transient probe + // failures (process abort, timeout) should not block a user with valid creds. + function applyRuntimeHealth( + status: AiProviderConnectionStatus, + health: ReturnType, + ): void { + if (health?.state === "auth-failed") { + status.runtimeAvailable = false; + status.blocker = health.message + ?? `${status.provider} runtime was detected, but ADE chat reported that login is still required.`; + } else if (health?.state === "ready") { + status.runtimeAvailable = true; + status.authAvailable = true; + status.blocker = null; + } + } const claude = createUnavailableStatus("claude", checkedAt); - claude.authAvailable = claudeAuthAvailable; - claude.runtimeDetected = claudeRuntimeDetected; - claude.runtimeAvailable = claudeRuntimeAvailable; + claude.authAvailable = claudeFlags.authAvailable; + claude.runtimeDetected = claudeFlags.runtimeDetected; + claude.runtimeAvailable = claudeFlags.runtimeAvailable; claude.usageAvailable = Boolean(claudeLocalCreds); claude.path = claudeCli?.path ?? null; claude.sources = [ @@ -71,34 +120,13 @@ export async function buildProviderConnections( path: claudeCli?.path ?? null, }, ]; - if (!claudeAuthAvailable && !claudeRuntimeDetected) { - claude.blocker = "No Claude authentication or CLI was found locally."; - } else if (!claudeAuthAvailable) { - claude.blocker = "Claude CLI is installed but no login was detected. Run: claude auth login"; - } else if (!claudeRuntimeDetected) { - claude.blocker = "Local credentials exist but the Claude CLI is not on ADE's PATH."; - } else { - claude.blocker = null; - } - // Only an explicit auth failure from the runtime probe should downgrade status. - // Transient failures (aborted, timeout, exit code 1) should NOT override - // the presence of valid local credentials + installed CLI. - if (claudeRuntimeHealth?.state === "auth-failed") { - claude.runtimeAvailable = false; - claude.blocker = claudeRuntimeHealth.message - ?? "Claude runtime was detected, but ADE chat reported that login is still required."; - } else if (claudeRuntimeHealth?.state === "ready") { - claude.runtimeAvailable = true; - claude.authAvailable = true; - claude.blocker = null; - } - // Note: "runtime-failed" is deliberately ignored — transient probe failures - // (process abort, timeout) should not block a user who has valid credentials. + claude.blocker = resolveBlocker("Claude", "claude auth login", claudeFlags); + applyRuntimeHealth(claude, claudeRuntimeHealth); const codex = createUnavailableStatus("codex", checkedAt); - codex.authAvailable = codexAuthAvailable; - codex.runtimeDetected = codexRuntimeDetected; - codex.runtimeAvailable = codexRuntimeAvailable; + codex.authAvailable = codexFlags.authAvailable; + codex.runtimeDetected = codexFlags.runtimeDetected; + codex.runtimeAvailable = codexFlags.runtimeAvailable; codex.usageAvailable = codexUsageAvailable; codex.path = codexCli?.path ?? null; codex.sources = [ @@ -116,26 +144,15 @@ export async function buildProviderConnections( path: codexCli?.path ?? null, }, ]; - if (!codexAuthAvailable && !codexRuntimeDetected) { - codex.blocker = "No Codex authentication or CLI was found locally."; - } else if (!codexAuthAvailable) { - codex.blocker = "Codex CLI is installed but no login was detected. Run: codex login"; - } else if (!codexRuntimeDetected) { - codex.blocker = "Local credentials exist but the Codex CLI is not on ADE's PATH."; - } else if (codexLocalCreds && isCodexTokenStale(codexLocalCreds)) { - codex.blocker = "Codex local auth exists, but the stored token looks stale for usage polling."; - } else { - codex.blocker = null; - } - if (codexRuntimeHealth?.state === "auth-failed") { - codex.runtimeAvailable = false; - codex.blocker = codexRuntimeHealth.message - ?? "Codex runtime was detected, but ADE chat reported that login is still required."; - } else if (codexRuntimeHealth?.state === "ready") { - codex.runtimeAvailable = true; - codex.authAvailable = true; - codex.blocker = null; - } + codex.blocker = resolveBlocker( + "Codex", + "codex login", + codexFlags, + codexLocalCreds && isCodexTokenStale(codexLocalCreds) + ? "Codex local auth exists, but the stored token looks stale for usage polling." + : null, + ); + applyRuntimeHealth(codex, codexRuntimeHealth); return { claude, codex }; } diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index 65b5d897..c1971596 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -42,6 +42,9 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo workerHeartbeatService: null, linearDispatcherService: null, flowPolicyService: null, + prService: null, + fileService: null, + processService: null, issueTracker: null, listChats: vi.fn().mockResolvedValue([]), getChatStatus: vi.fn().mockResolvedValue(null), @@ -55,7 +58,11 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo updateChatSession: vi.fn().mockResolvedValue(baseSession), sendChatMessage: vi.fn().mockResolvedValue(undefined), interruptChat: vi.fn().mockResolvedValue(undefined), + resumeChat: vi.fn().mockResolvedValue(baseSession), + disposeChat: vi.fn().mockResolvedValue(undefined), ensureCtoSession: vi.fn().mockResolvedValue({ ...baseSession, id: "cto-session" }), + listContextPacks: vi.fn().mockResolvedValue([]), + fetchContextPack: vi.fn().mockResolvedValue({ scope: "mission", content: "ctx", truncated: false }), fetchMissionContext: vi.fn().mockResolvedValue({ content: "ctx", truncated: false }), ...overrides, }; @@ -103,7 +110,8 @@ describe("createCtoOperatorTools", () => { expect(result).toMatchObject({ success: true, sessionId: "chat-1", - navigation: { surface: "lanes", laneId: "lane-1", sessionId: "chat-1" }, + navigation: { surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }, + navigationSuggestions: [{ surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }], requestedTitle: "Backend follow-up", }); }); @@ -166,6 +174,39 @@ describe("createCtoOperatorTools", () => { expect(resolved).toMatchObject({ success: true, intervention }); }); + it("returns lane and mission navigation suggestions for operator-created ADE objects", async () => { + const lane = { id: "lane-2", name: "ops", branchRef: "refs/heads/ops" }; + const mission = { id: "mission-7", title: "Mission", laneId: "lane-2" }; + const deps = buildDeps({ + laneService: { + list: vi.fn().mockResolvedValue([lane]), + create: vi.fn().mockResolvedValue(lane), + } as any, + missionService: { + create: vi.fn().mockReturnValue(mission), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const createdLane = await (tools.createLane as any).execute({ + name: "ops", + }); + const startedMission = await (tools.startMission as any).execute({ + prompt: "Investigate the failing deploy path.", + laneId: "lane-2", + launch: false, + }); + + expect(createdLane).toMatchObject({ + success: true, + navigation: { surface: "lanes", laneId: "lane-2", href: "/lanes?laneId=lane-2" }, + }); + expect(startedMission).toMatchObject({ + success: true, + navigation: { surface: "missions", laneId: "lane-2", missionId: "mission-7", href: "/missions?missionId=mission-7&laneId=lane-2" }, + }); + }); + it("surfaces mission runtime view, logs, worker digests, and steering through aiOrchestratorService", async () => { const runView = { missionId: "mission-1", displayStatus: "running" }; const logs = { entries: [{ id: "log-1" }], nextCursor: null, total: 1 }; @@ -328,7 +369,7 @@ describe("createCtoOperatorTools", () => { cancelledExistingRun: false, rerouted: { success: true, - navigation: { surface: "lanes", laneId: "lane-1", sessionId: "cto-recovery" }, + navigation: { surface: "cto", laneId: "lane-1", sessionId: "cto-recovery", href: "/cto" }, }, }); }); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 4447a621..06dd63fa 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -9,17 +9,24 @@ import type { AgentChatSessionSummary, AgentStatus, AgentUpsertInput, + ContextPackFetchArgs, + ContextPackFetchResult, + ContextPackOption, CtoTriggerAgentWakeupArgs, LinearWorkflowConfig, + OperatorNavigationSuggestion, } from "../../../../shared/types"; import type { IssueTracker } from "../../cto/issueTracker"; import type { createLinearDispatcherService } from "../../cto/linearDispatcherService"; import type { createWorkerAgentService } from "../../cto/workerAgentService"; import type { createWorkerHeartbeatService } from "../../cto/workerHeartbeatService"; import type { createFlowPolicyService } from "../../cto/flowPolicyService"; +import type { createFileService } from "../../files/fileService"; import type { createLaneService } from "../../lanes/laneService"; import type { createMissionService } from "../../missions/missionService"; import type { createAiOrchestratorService } from "../../orchestrator/aiOrchestratorService"; +import type { createPrService } from "../../prs/prService"; +import type { createProcessService } from "../../processes/processService"; export interface CtoOperatorToolDeps { currentSessionId: string; @@ -33,6 +40,9 @@ export interface CtoOperatorToolDeps { workerHeartbeatService?: ReturnType | null; linearDispatcherService?: ReturnType | null; flowPolicyService?: ReturnType | null; + prService?: ReturnType | null; + fileService?: ReturnType | null; + processService?: ReturnType | null; issueTracker?: IssueTracker | null; listChats: (laneId?: string, options?: { includeIdentity?: boolean; includeAutomation?: boolean }) => Promise; getChatStatus: (sessionId: string) => Promise; @@ -58,21 +68,19 @@ export interface CtoOperatorToolDeps { }) => Promise; sendChatMessage: (args: AgentChatSendArgs) => Promise; interruptChat: (args: AgentChatInterruptArgs) => Promise; + resumeChat: (args: { sessionId: string }) => Promise; + disposeChat: (args: { sessionId: string }) => Promise; ensureCtoSession: (args: { laneId: string; modelId?: string | null; reasoningEffort?: string | null; reuseExisting?: boolean; }) => Promise; + listContextPacks?: (args?: { laneId?: string }) => Promise; + fetchContextPack?: (args: ContextPackFetchArgs) => Promise; fetchMissionContext?: (missionId: string) => Promise<{ content: string; truncated: boolean }>; } -type ChatNavigationHint = { - surface: "lanes"; - laneId: string; - sessionId: string; -}; - const ACTIVE_LINEAR_RUN_STATUSES = new Set([ "queued", "in_progress", @@ -128,14 +136,94 @@ function summarizeWorkerStatus(status: AgentStatus): string { } } -function buildChatNavigationHint(session: Pick): ChatNavigationHint { +function buildNavigationSuggestion(args: { + surface: OperatorNavigationSuggestion["surface"]; + laneId?: string | null; + sessionId?: string | null; + missionId?: string | null; +}): OperatorNavigationSuggestion { + const laneId = args.laneId?.trim() || null; + const sessionId = args.sessionId?.trim() || null; + const missionId = args.missionId?.trim() || null; + if (args.surface === "work") { + const search = new URLSearchParams(); + if (laneId) search.set("laneId", laneId); + if (sessionId) search.set("sessionId", sessionId); + return { + surface: "work", + label: "Open in Work", + href: `/work${search.size ? `?${search.toString()}` : ""}`, + laneId, + sessionId, + }; + } + if (args.surface === "missions") { + const search = new URLSearchParams(); + if (missionId) search.set("missionId", missionId); + if (laneId) search.set("laneId", laneId); + return { + surface: "missions", + label: "Open mission", + href: `/missions${search.size ? `?${search.toString()}` : ""}`, + laneId, + missionId, + }; + } + if (args.surface === "cto") { + return { + surface: "cto", + label: "Open CTO", + href: "/cto", + laneId, + sessionId, + }; + } + const search = new URLSearchParams(); + if (laneId) search.set("laneId", laneId); + if (sessionId) search.set("sessionId", sessionId); return { surface: "lanes", - laneId: session.laneId, - sessionId: session.id, + label: "Open lane", + href: `/lanes${search.size ? `?${search.toString()}` : ""}`, + laneId, + sessionId, + }; +} + +function buildNavigationPayload( + suggestion: OperatorNavigationSuggestion | null, + includeSuggestions = true, +): { + navigation?: OperatorNavigationSuggestion; + navigationSuggestions?: OperatorNavigationSuggestion[]; +} { + if (!includeSuggestions || !suggestion) return {}; + return { + navigation: suggestion, + navigationSuggestions: [suggestion], }; } +function resolveWorkspaceIdForLane( + deps: Pick, + args: { workspaceId?: string | null; laneId?: string | null }, +): string { + if (!deps.fileService) { + throw new Error("File service is not available."); + } + const workspaces = deps.fileService.listWorkspaces({ includeArchived: true }); + const explicitWorkspaceId = args.workspaceId?.trim() || ""; + if (explicitWorkspaceId) { + const workspace = workspaces.find((entry) => entry.id === explicitWorkspaceId) ?? null; + if (!workspace) throw new Error(`Workspace not found: ${explicitWorkspaceId}`); + return workspace.id; + } + const laneId = args.laneId?.trim() || deps.defaultLaneId; + const laneWorkspace = workspaces.find((entry) => entry.laneId === laneId) ?? null; + if (laneWorkspace) return laneWorkspace.id; + throw new Error(`Workspace not found for lane ${laneId}.`); +} + export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { const tools: Record = {}; @@ -169,7 +257,11 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { try { const lane = await deps.laneService.create({ name, description, parentLaneId }); - return { success: true, lane }; + return { + success: true, + lane, + ...buildNavigationPayload(buildNavigationSuggestion({ + surface: "lanes", + laneId: lane.id, + })), + }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } @@ -365,7 +480,11 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + try { + const session = await deps.resumeChat({ sessionId }); + return { + success: true, + sessionId: session.id, + laneId: session.laneId, + status: session.status, + ...buildNavigationPayload(buildNavigationSuggestion({ + surface: "work", + laneId: session.laneId, + sessionId: session.id, + })), + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.endChat = tool({ + description: "End and archive an ADE chat session.", + inputSchema: z.object({ + sessionId: z.string(), + }), + execute: async ({ sessionId }) => { + try { + await deps.disposeChat({ sessionId }); + return { success: true, sessionId }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + tools.getChatStatus = tool({ description: "Get the current status for an ADE chat session.", inputSchema: z.object({ @@ -489,7 +648,16 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + const prs = refresh ? await deps.prService.refresh() : deps.prService.listAll(); + return { success: true, count: prs.length, prs }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.getPullRequestStatus = tool({ + description: "Inspect pull request status, checks, reviews, and comments through ADE's PR service.", + inputSchema: z.object({ + prId: z.string(), + includeChecks: z.boolean().optional().default(true), + includeReviews: z.boolean().optional().default(true), + includeComments: z.boolean().optional().default(false), + }), + execute: async ({ prId, includeChecks, includeReviews, includeComments }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + const summary = deps.prService.listAll().find((entry) => entry.id === prId) ?? null; + const [status, checks, reviews, comments] = await Promise.all([ + deps.prService.getStatus(prId), + includeChecks ? deps.prService.getChecks(prId) : Promise.resolve([]), + includeReviews ? deps.prService.getReviews(prId) : Promise.resolve([]), + includeComments ? deps.prService.getComments(prId) : Promise.resolve([]), + ]); + return { + success: true, + prId, + summary, + status, + checks, + reviews, + comments, + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.commentOnPullRequest = tool({ + description: "Post a comment to a pull request through ADE's PR service.", + inputSchema: z.object({ + prId: z.string(), + body: z.string(), + }), + execute: async ({ prId, body }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + const comment = await deps.prService.addComment({ prId, body }); + return { success: true, comment }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.updatePullRequestTitle = tool({ + description: "Update a pull request title through ADE's PR service.", + inputSchema: z.object({ + prId: z.string(), + title: z.string(), + }), + execute: async ({ prId, title }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + await deps.prService.updateTitle({ prId, title }); + return { success: true, prId, title }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.updatePullRequestBody = tool({ + description: "Update a pull request body through ADE's PR service.", + inputSchema: z.object({ + prId: z.string(), + body: z.string(), + }), + execute: async ({ prId, body }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + await deps.prService.updateDescription({ prId, body }); + return { success: true, prId }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.listFileWorkspaces = tool({ + description: "List ADE file workspaces so the CTO can inspect files by lane or attached workspace.", + inputSchema: z.object({ + includeArchived: z.boolean().optional().default(true), + }), + execute: async ({ includeArchived }) => { + if (!deps.fileService) return { success: false, error: "File service is not available." }; + const workspaces = deps.fileService.listWorkspaces({ includeArchived }); + return { success: true, count: workspaces.length, workspaces }; + }, + }); + + tools.readWorkspaceFile = tool({ + description: "Read a file from an ADE workspace or lane without opening the renderer editor.", + inputSchema: z.object({ + workspaceId: z.string().optional(), + laneId: z.string().optional(), + path: z.string(), + }), + execute: async ({ workspaceId, laneId, path }) => { + if (!deps.fileService) return { success: false, error: "File service is not available." }; + try { + const resolvedWorkspaceId = resolveWorkspaceIdForLane(deps, { + workspaceId, + laneId, + }); + const file = deps.fileService.readFile({ workspaceId: resolvedWorkspaceId, path }); + return { + success: true, + workspaceId: resolvedWorkspaceId, + path, + file, + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.searchWorkspaceText = tool({ + description: "Search indexed text inside an ADE workspace or lane.", + inputSchema: z.object({ + workspaceId: z.string().optional(), + laneId: z.string().optional(), + query: z.string(), + limit: z.number().int().positive().max(200).optional().default(50), + }), + execute: async ({ workspaceId, laneId, query, limit }) => { + if (!deps.fileService) return { success: false, error: "File service is not available." }; + try { + const resolvedWorkspaceId = resolveWorkspaceIdForLane(deps, { + workspaceId, + laneId, + }); + const matches = await deps.fileService.searchText({ + workspaceId: resolvedWorkspaceId, + query, + limit, + }); + return { + success: true, + workspaceId: resolvedWorkspaceId, + count: matches.length, + matches, + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.listContextExports = tool({ + description: "List ADE context export scopes that the CTO can request through internal services.", + inputSchema: z.object({ + laneId: z.string().optional(), + }), + execute: async ({ laneId }) => { + if (!deps.listContextPacks) return { success: false, error: "Context export service is not available." }; + try { + const packs = await deps.listContextPacks({ laneId: laneId?.trim() || undefined }); + return { success: true, count: packs.length, packs }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.exportContext = tool({ + description: "Export a bounded ADE context pack for a project, lane, mission, conflict, plan, or feature scope.", + inputSchema: z.object({ + scope: z.enum(["project", "lane", "conflict", "plan", "feature", "mission"]), + level: z.enum(["brief", "standard", "detailed"]).optional().default("standard"), + laneId: z.string().optional(), + missionId: z.string().optional(), + featureKey: z.string().optional(), + }), + execute: async ({ scope, level, laneId, missionId, featureKey }) => { + if (!deps.fetchContextPack) return { success: false, error: "Context export service is not available." }; + try { + const result = await deps.fetchContextPack({ + scope, + level, + laneId: laneId?.trim() || undefined, + missionId: missionId?.trim() || undefined, + featureKey: featureKey?.trim() || undefined, + }); + const mission = missionId?.trim() ? deps.missionService?.get(missionId.trim()) ?? null : null; + return { + success: true, + ...result, + ...buildNavigationPayload( + scope === "mission" + ? buildNavigationSuggestion({ + surface: "missions", + laneId: mission?.laneId ?? (laneId?.trim() || null), + missionId: missionId?.trim() || null, + }) + : scope === "lane" + ? buildNavigationSuggestion({ + surface: "lanes", + laneId: laneId?.trim() || null, + }) + : null, + ), + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.listManagedProcesses = tool({ + description: "Inspect ADE-managed processes for a lane, including configured definitions and current runtime state.", + inputSchema: z.object({ + laneId: z.string().optional(), + }), + execute: async ({ laneId }) => { + if (!deps.processService) return { success: false, error: "Process service is not available." }; + const resolvedLaneId = laneId?.trim() || deps.defaultLaneId; + try { + const [definitions, runtime] = await Promise.all([ + Promise.resolve(deps.processService.listDefinitions()), + Promise.resolve(deps.processService.listRuntime(resolvedLaneId)), + ]); + return { + success: true, + laneId: resolvedLaneId, + definitions, + runtime, + ...buildNavigationPayload(buildNavigationSuggestion({ + surface: "lanes", + laneId: resolvedLaneId, + })), + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.startManagedProcess = tool({ + description: "Start an ADE-managed lane process.", + inputSchema: z.object({ + laneId: z.string().optional(), + processId: z.string(), + }), + execute: async ({ laneId, processId }) => { + if (!deps.processService) return { success: false, error: "Process service is not available." }; + try { + const runtime = await deps.processService.start({ + laneId: laneId?.trim() || deps.defaultLaneId, + processId, + }); + return { success: true, runtime }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.stopManagedProcess = tool({ + description: "Stop an ADE-managed lane process.", + inputSchema: z.object({ + laneId: z.string().optional(), + processId: z.string(), + }), + execute: async ({ laneId, processId }) => { + if (!deps.processService) return { success: false, error: "Process service is not available." }; + try { + const runtime = await deps.processService.stop({ + laneId: laneId?.trim() || deps.defaultLaneId, + processId, + }); + return { success: true, runtime }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + + tools.getManagedProcessLog = tool({ + description: "Read the bounded tail of an ADE-managed process log.", + inputSchema: z.object({ + laneId: z.string().optional(), + processId: z.string(), + maxBytes: z.number().int().positive().max(500_000).optional().default(40_000), + }), + execute: async ({ laneId, processId, maxBytes }) => { + if (!deps.processService) return { success: false, error: "Process service is not available." }; + try { + const content = deps.processService.getLogTail({ + laneId: laneId?.trim() || deps.defaultLaneId, + processId, + maxBytes, + }); + return { success: true, laneId: laneId?.trim() || deps.defaultLaneId, processId, content }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + }); + tools.listLinearWorkflows = tool({ description: "List active and queued Linear workflow runs managed by ADE.", inputSchema: z.object({}), diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 169919d0..ca7e36cb 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -27,7 +27,9 @@ import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createSessionService } from "../sessions/sessionService"; import type { createProjectConfigService } from "../config/projectConfigService"; +import type { createFileService } from "../files/fileService"; import type { createPackService } from "../packs/packService"; +import type { createProcessService } from "../processes/processService"; import { runGit } from "../git/git"; import { CLAUDE_RUNTIME_AUTH_ERROR, isClaudeRuntimeAuthError } from "../ai/claudeRuntimeProbe"; import { nowIso, fileSizeOrZero } from "../shared/utils"; @@ -196,6 +198,8 @@ type ClaudeRuntime = { v2StreamGen: AsyncGenerator | null; /** Resolves when the subprocess is initialized (system:init received). */ v2WarmupDone: Promise | null; + /** Resolves the current warmup race so waiters can stop blocking immediately. */ + v2WarmupCancel: (() => void) | null; /** Set to true when teardown runs to cancel an in-flight warmup. */ v2WarmupCancelled: boolean; activeSubagents: Map; @@ -300,6 +304,15 @@ type SessionTurnCollector = { timeout: NodeJS.Timeout; }; +type PreparedSendMessage = { + sessionId: string; + managed: ManagedChatSession; + promptText: string; + visibleText: string; + attachments: AgentChatFileRef[]; + reasoningEffort?: string | null; +}; + type ResolvedChatConfig = { codexApprovalPolicy: "untrusted" | "on-request" | "on-failure" | "never"; codexSandboxMode: "read-only" | "workspace-write" | "danger-full-access"; @@ -988,6 +1001,7 @@ export function createAgentChatService(args: { projectId?: string; memoryService?: ReturnType | null; packService?: ReturnType | null; + fileService?: ReturnType | null; episodicSummaryService?: EpisodicSummaryService | null; ctoStateService?: ReturnType | null; workerAgentService?: ReturnType | null; @@ -1000,6 +1014,7 @@ export function createAgentChatService(args: { linearClient?: import("../cto/linearClient").LinearClient | null; linearCredentials?: import("../cto/linearCredentialService").LinearCredentialService | null; prService?: ReturnType | null; + processService?: ReturnType | null; computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; laneService: ReturnType; sessionService: ReturnType; @@ -1015,6 +1030,7 @@ export function createAgentChatService(args: { projectId, memoryService, packService, + fileService, episodicSummaryService, ctoStateService, workerAgentService, @@ -1027,6 +1043,7 @@ export function createAgentChatService(args: { linearClient: linearClientRef, linearCredentials: linearCredentialsRef, prService, + processService, computerUseArtifactBrokerService, laneService, sessionService, @@ -1064,7 +1081,7 @@ export function createAgentChatService(args: { }; const trackSubagentEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => { - if (event.type !== "subagent_started" && event.type !== "subagent_result") return; + if (event.type !== "subagent_started" && event.type !== "subagent_progress" && event.type !== "subagent_result") return; const map = ensureSubagentSnapshotMap(managed.session.id); if (event.type === "subagent_started") { map.set(event.taskId, { @@ -1076,6 +1093,20 @@ export function createAgentChatService(args: { }); return; } + if (event.type === "subagent_progress") { + const previous = map.get(event.taskId); + map.set(event.taskId, { + taskId: event.taskId, + description: event.description?.trim() || previous?.description || "Subagent task", + status: "running", + turnId: event.turnId ?? previous?.turnId, + startTimestamp: previous?.startTimestamp ?? nowIso(), + summary: event.summary.trim() || previous?.summary, + lastToolName: event.lastToolName ?? previous?.lastToolName, + usage: event.usage ?? previous?.usage, + }); + return; + } const previous = map.get(event.taskId); const status = event.status === "failed" ? "failed" @@ -1090,6 +1121,7 @@ export function createAgentChatService(args: { startTimestamp: previous?.startTimestamp, endTimestamp: nowIso(), summary: event.summary ?? previous?.summary, + lastToolName: previous?.lastToolName, usage: event.usage ?? previous?.usage, }); }; @@ -1948,7 +1980,7 @@ export function createAgentChatService(args: { managed.runtime = null; } if (managed.runtime?.kind === "claude") { - managed.runtime.v2WarmupCancelled = true; + cancelClaudeWarmup(managed, managed.runtime, "teardown"); managed.runtime.activeQuery?.close(); managed.runtime.activeQuery = null; try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } @@ -2432,6 +2464,19 @@ export function createAgentChatService(args: { let assistantText = ""; let usage: { inputTokens?: number | null; outputTokens?: number | null; cacheReadTokens?: number | null; cacheCreationTokens?: number | null } | undefined; let costUsd: number | null = null; + const turnStartedAt = Date.now(); + let firstStreamEventLogged = false; + const markFirstStreamEvent = (kind: string): void => { + if (firstStreamEventLogged) return; + firstStreamEventLogged = true; + logger.info("agent_chat.turn_first_event", { + sessionId: managed.session.id, + provider: "claude", + turnId, + kind, + latencyMs: Date.now() - turnStartedAt, + }); + }; try { const claudeDescriptor = resolveSessionModelDescriptor(managed.session); @@ -2440,7 +2485,20 @@ export function createAgentChatService(args: { // ── V2 persistent session with background pre-warming ── // The pre-warm was kicked off in ensureClaudeSessionRuntime. Wait for it. if (runtime.v2WarmupDone) { + const warmupWaitStartedAt = Date.now(); + logger.info("agent_chat.claude_v2_turn_waiting_for_warmup", { + sessionId: managed.session.id, + turnId, + }); await runtime.v2WarmupDone; + logger.info("agent_chat.claude_v2_turn_warmup_wait_done", { + sessionId: managed.session.id, + turnId, + waitedMs: Date.now() - warmupWaitStartedAt, + }); + } + if (runtime.interrupted) { + throw new Error("Claude turn interrupted during warmup."); } // Fallback: if pre-warm failed or didn't run, create session on the fly if (!runtime.v2Session) { @@ -2467,6 +2525,7 @@ export function createAgentChatService(args: { for await (const msg of runtime.v2Session.stream()) { if (runtime.interrupted) break; + markFirstStreamEvent(msg.type); // Capture session_id from any message if (!runtime.sdkSessionId && (msg as any).session_id) { @@ -2479,20 +2538,14 @@ export function createAgentChatService(args: { const initMsg = msg as any; runtime.sdkSessionId = initMsg.session_id ?? runtime.sdkSessionId; if (Array.isArray(initMsg.slash_commands)) { - runtime.slashCommands = initMsg.slash_commands - .filter((cmd: unknown) => typeof cmd === "string" && cmd.length > 0) - .map((cmd: string) => ({ name: cmd.startsWith("/") ? cmd : `/${cmd}`, description: "" })); + applyClaudeSlashCommands(runtime, initMsg.slash_commands); } try { const sessionImpl = runtime.v2Session as any; if (typeof sessionImpl?.supportedCommands === "function") { sessionImpl.supportedCommands().then((cmds: any[]) => { if (Array.isArray(cmds) && cmds.length > 0) { - runtime.slashCommands = cmds.map((c: any) => ({ - name: typeof c.name === "string" ? (c.name.startsWith("/") ? c.name : `/${c.name}`) : String(c), - description: typeof c.description === "string" ? c.description : "", - argumentHint: typeof c.argumentHint === "string" ? c.argumentHint : undefined, - })); + applyClaudeSlashCommands(runtime, cmds); } }).catch(() => { /* not available */ }); } @@ -2606,6 +2659,31 @@ export function createAgentChatService(args: { continue; } + // system:task_progress — running subagent summary/usage + if (msg.type === "system" && (msg as any).subtype === "task_progress") { + const taskMsg = msg as any; + const taskId = String(taskMsg.task_id ?? ""); + const existing = runtime.activeSubagents.get(taskId); + const description = String(taskMsg.description ?? existing?.description ?? ""); + if (taskId.length) { + runtime.activeSubagents.set(taskId, { taskId, description }); + } + emitChatEvent(managed, { + type: "subagent_progress", + taskId, + description, + summary: String(taskMsg.summary ?? ""), + usage: taskMsg.usage ? { + totalTokens: typeof taskMsg.usage.total_tokens === "number" ? taskMsg.usage.total_tokens : undefined, + toolUses: typeof taskMsg.usage.tool_uses === "number" ? taskMsg.usage.tool_uses : undefined, + durationMs: typeof taskMsg.usage.duration_ms === "number" ? taskMsg.usage.duration_ms : undefined, + } : undefined, + lastToolName: typeof taskMsg.last_tool_name === "string" ? taskMsg.last_tool_name : undefined, + turnId, + }); + continue; + } + // system:task_started — subagent spawn if (msg.type === "system" && (msg as any).subtype === "task_started") { const taskMsg = msg as any; @@ -3031,6 +3109,19 @@ export function createAgentChatService(args: { let assistantText = ""; let usage: { inputTokens?: number | null; outputTokens?: number | null } | undefined; let streamedStepCount = 0; + const turnStartedAt = Date.now(); + let firstStreamEventLogged = false; + const markFirstStreamEvent = (kind: string): void => { + if (firstStreamEventLogged) return; + firstStreamEventLogged = true; + logger.info("agent_chat.turn_first_event", { + sessionId: managed.session.id, + provider: managed.session.provider, + turnId, + kind, + latencyMs: Date.now() - turnStartedAt, + }); + }; try { const lightweight = isLightweightSession(managed.session); @@ -3168,6 +3259,9 @@ export function createAgentChatService(args: { workerHeartbeatService: workerHeartbeatService ?? null, linearDispatcherService: getLinearDispatcherService?.() ?? null, flowPolicyService: flowPolicyService ?? null, + prService: prService ?? null, + fileService: fileService ?? null, + processService: processService ?? null, issueTracker: linearIssueTracker ?? null, listChats: listSessions, getChatStatus: getSessionSummary, @@ -3176,6 +3270,8 @@ export function createAgentChatService(args: { updateChatSession: updateSession, sendChatMessage: sendMessage, interruptChat: interrupt, + resumeChat: resumeSession, + disposeChat: dispose, ensureCtoSession: async ({ laneId, modelId, reasoningEffort, reuseExisting }) => ensureIdentitySession({ identityKey: "cto", @@ -3185,6 +3281,8 @@ export function createAgentChatService(args: { reuseExisting, permissionMode: "full-auto", }), + listContextPacks, + fetchContextPack, fetchMissionContext: async (missionId) => { const result = await fetchContextPack({ scope: "mission", missionId, level: "standard" }); return { @@ -3237,6 +3335,7 @@ export function createAgentChatService(args: { const streamSupportsReasoning = runtime.modelDescriptor.capabilities.reasoning; for await (const part of stream.fullStream as AsyncIterable) { if (!part || typeof part !== "object") continue; + markFirstStreamEvent(String(part.type ?? "unknown")); if (part.type === "start-step") { streamedStepCount += 1; @@ -4217,6 +4316,7 @@ export function createAgentChatService(args: { cwd: managed.laneWorktreePath, permissionMode: claudePermissionMode as any, includePartialMessages: true, + agentProgressSummaries: true, maxBudgetUsd: chatConfig.sessionBudgetUsd ?? undefined, model: resolveClaudeCliModel(managed.session.model), }; @@ -4264,11 +4364,49 @@ export function createAgentChatService(args: { return { ...opts, model }; }; + const cancelClaudeWarmup = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + reason: "interrupt" | "teardown" | "session_reset", + ): void => { + if (!runtime.v2WarmupDone) return; + runtime.v2WarmupCancelled = true; + runtime.v2WarmupCancel?.(); + logger.info("agent_chat.claude_v2_prewarm_cancel", { + sessionId: managed.session.id, + reason, + }); + }; + + const applyClaudeSlashCommands = ( + runtime: ClaudeRuntime, + commands: Array, + ): void => { + runtime.slashCommands = commands + .map((command) => { + if (typeof command === "string") { + const normalized = command.trim(); + if (!normalized.length) return null; + return { + name: normalized.startsWith("/") ? normalized : `/${normalized}`, + description: "", + }; + } + const normalized = typeof command.name === "string" ? command.name.trim() : ""; + if (!normalized.length) return null; + return { + name: normalized.startsWith("/") ? normalized : `/${normalized}`, + description: typeof command.description === "string" ? command.description : "", + argumentHint: typeof command.argumentHint === "string" ? command.argumentHint : undefined, + }; + }) + .filter((command): command is { name: string; description: string; argumentHint?: string } => Boolean(command)); + }; + /** * Pre-warm the Claude V2 session in the background. - * Creates the session and sends a warmup turn so the subprocess + MCP servers - * are fully initialized by the time the user sends their first real message. - * The warmup turn (~30s cold start) runs while the user is composing their message. + * Creates the persistent session and runs a silent warmup turn because the + * public V2 session API does not expose an init-only readiness handshake. */ const prewarmClaudeV2Session = (managed: ManagedChatSession): void => { const runtime = managed.runtime; @@ -4276,8 +4414,18 @@ export function createAgentChatService(args: { if (runtime.v2Session || runtime.v2WarmupDone) return; runtime.v2WarmupCancelled = false; + const warmupStartedAt = Date.now(); + let settleWarmupWaiters: (() => void) | null = null; + const waitForCancel = new Promise((resolve) => { + settleWarmupWaiters = resolve; + }); + const cancelWarmup = () => { + settleWarmupWaiters?.(); + settleWarmupWaiters = null; + }; + runtime.v2WarmupCancel = cancelWarmup; - runtime.v2WarmupDone = (async () => { + const warmupTask = (async () => { try { const v2Opts = buildClaudeV2SessionOpts(managed, runtime); logger.info("agent_chat.claude_v2_prewarm_start", { @@ -4300,9 +4448,6 @@ export function createAgentChatService(args: { return; } - // Send a warmup turn — this triggers the ~30s subprocess cold start. - // The response is consumed silently. By the time the user types and sends - // their first real message, the subprocess is already running. await runtime.v2Session.send("System initialization check. Respond with only the word READY."); for await (const msg of runtime.v2Session.stream()) { if (runtime.v2WarmupCancelled) break; @@ -4313,20 +4458,14 @@ export function createAgentChatService(args: { const initMsg = msg as any; runtime.sdkSessionId = initMsg.session_id ?? runtime.sdkSessionId; if (Array.isArray(initMsg.slash_commands)) { - runtime.slashCommands = initMsg.slash_commands - .filter((cmd: unknown) => typeof cmd === "string" && cmd.length > 0) - .map((cmd: string) => ({ name: cmd.startsWith("/") ? cmd : `/${cmd}`, description: "" })); + applyClaudeSlashCommands(runtime, initMsg.slash_commands); } try { const sessionImpl = runtime.v2Session as any; if (typeof sessionImpl?.supportedCommands === "function") { sessionImpl.supportedCommands().then((cmds: any[]) => { if (Array.isArray(cmds) && cmds.length > 0) { - runtime.slashCommands = cmds.map((c: any) => ({ - name: typeof c.name === "string" ? (c.name.startsWith("/") ? c.name : `/${c.name}`) : String(c), - description: typeof c.description === "string" ? c.description : "", - argumentHint: typeof c.argumentHint === "string" ? c.argumentHint : undefined, - })); + applyClaudeSlashCommands(runtime, cmds); } }).catch(() => { /* not available */ }); } @@ -4375,6 +4514,23 @@ export function createAgentChatService(args: { runtime.v2Session = null; } })(); + + const warmupPromise = Promise.race([warmupTask, waitForCancel]); + runtime.v2WarmupDone = warmupPromise; + + void warmupPromise.finally(() => { + if (runtime.v2WarmupDone === warmupPromise) { + runtime.v2WarmupDone = null; + } + if (runtime.v2WarmupCancel === cancelWarmup) { + runtime.v2WarmupCancel = null; + } + logger.info("agent_chat.claude_v2_prewarm_settled", { + sessionId: managed.session.id, + cancelled: runtime.v2WarmupCancelled, + durationMs: Date.now() - warmupStartedAt, + }); + }); }; const ensureClaudeSessionRuntime = (managed: ManagedChatSession): ClaudeRuntime => { @@ -4389,6 +4545,7 @@ export function createAgentChatService(args: { v2Session: null, v2StreamGen: null, v2WarmupDone: null, + v2WarmupCancel: null, v2WarmupCancelled: false, activeSubagents: new Map(), slashCommands: [], @@ -4721,16 +4878,16 @@ export function createAgentChatService(args: { return managed.session; }; - const sendMessage = async ({ + const prepareSendMessage = ({ sessionId, text, displayText, attachments = [], reasoningEffort, executionMode, - }: AgentChatSendArgs): Promise => { + }: AgentChatSendArgs): PreparedSendMessage | null => { const trimmed = text.trim(); - if (!trimmed.length) return; + if (!trimmed.length) return null; const slashCommand = extractSlashCommand(trimmed); const visibleText = displayText?.trim().length ? displayText.trim() : trimmed; @@ -4775,6 +4932,75 @@ export function createAgentChatService(args: { managed.session.executionMode = "focused"; } + return { + sessionId, + managed, + promptText, + visibleText, + attachments, + reasoningEffort, + }; + }; + + const emitDispatchedSendFailure = (prepared: PreparedSendMessage, error: unknown): void => { + const { managed } = prepared; + if (managed.closed) return; + + const message = error instanceof Error ? error.message : String(error); + const turnId = randomUUID(); + managed.session.status = "idle"; + + if (managed.runtime?.kind === "codex") { + managed.runtime.activeTurnId = null; + managed.runtime.startedTurnId = null; + } + if (managed.runtime?.kind === "unified") { + managed.runtime.busy = false; + managed.runtime.activeTurnId = null; + managed.runtime.abortController = null; + } + if (managed.runtime?.kind === "claude") { + managed.runtime.busy = false; + managed.runtime.activeTurnId = null; + managed.runtime.activeQuery = null; + } + + emitChatEvent(managed, { + type: "error", + message, + turnId, + }); + emitChatEvent(managed, { + type: "status", + turnStatus: "failed", + message, + turnId, + }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "failed", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + + appendWorkerActivityToCto(managed, { + activityType: "chat_turn", + summary: `Turn failed before execution: ${message}`, + }); + persistChatState(managed); + }; + + const executePreparedSendMessage = async (prepared: PreparedSendMessage): Promise => { + const { + sessionId, + managed, + promptText, + visibleText, + attachments, + reasoningEffort, + } = prepared; + // Unified runtime dispatch if (managed.session.provider === "unified") { if (!managed.runtime || managed.runtime.kind !== "unified") { @@ -4866,6 +5092,28 @@ export function createAgentChatService(args: { await runClaudeTurn(managed, { promptText, displayText: visibleText, attachments }); }; + const sendMessage = async (args: AgentChatSendArgs): Promise => { + const dispatchStartedAt = Date.now(); + const prepared = prepareSendMessage(args); + if (!prepared) return; + + logger.info("agent_chat.turn_dispatch_ack", { + sessionId: prepared.sessionId, + provider: prepared.managed.session.provider, + model: prepared.managed.session.model, + durationMs: Date.now() - dispatchStartedAt, + }); + + void executePreparedSendMessage(prepared).catch((error) => { + logger.warn("agent_chat.turn_dispatch_failed", { + sessionId: prepared.sessionId, + provider: prepared.managed.session.provider, + error: error instanceof Error ? error.message : String(error), + }); + emitDispatchedSendFailure(prepared, error); + }); + }; + const steer = async ({ sessionId, text }: AgentChatSteerArgs): Promise => { const trimmed = text.trim(); if (!trimmed.length) return; @@ -4951,13 +5199,27 @@ export function createAgentChatService(args: { } const runtime = ensureClaudeSessionRuntime(managed); + logger.info("agent_chat.turn_interrupt_requested", { + sessionId, + provider: "claude", + turnId: runtime.activeTurnId, + busy: runtime.busy, + warmupInFlight: Boolean(runtime.v2WarmupDone), + }); runtime.interrupted = true; + cancelClaudeWarmup(managed, runtime, "interrupt"); runtime.activeQuery?.interrupt().catch(() => {}); // Close the V2 session on interrupt — it will be recreated on the next turn try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; runtime.v2StreamGen = null; - runtime.v2WarmupDone = null; + runtime.activeSubagents.clear(); + logger.info("agent_chat.turn_interrupt_completed", { + sessionId, + provider: "claude", + turnId: runtime.activeTurnId, + busy: runtime.busy, + }); }; const resumeSession = async ({ sessionId }: { sessionId: string }): Promise => { @@ -5459,8 +5721,9 @@ export function createAgentChatService(args: { // When reasoning effort changes on a Claude session with an active V2 // session, invalidate the V2 session so it is recreated on the next turn // with the updated thinking configuration. - if (prev !== next && managed.runtime?.kind === "claude" && managed.runtime.v2Session) { - managed.runtime.v2Session.close(); + if (prev !== next && managed.runtime?.kind === "claude" && (managed.runtime.v2Session || managed.runtime.v2WarmupDone)) { + cancelClaudeWarmup(managed, managed.runtime, "session_reset"); + try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } managed.runtime.v2Session = null; managed.runtime.v2WarmupDone = null; } @@ -5514,7 +5777,7 @@ export function createAgentChatService(args: { if (!isAnthropicCli) return; // Only prewarm if the session is idle (not mid-turn) and not already warmed - if (managed.runtime?.kind === "claude" && managed.runtime.v2WarmupDone) return; + if (managed.runtime?.kind === "claude" && (managed.runtime.v2Session || managed.runtime.v2WarmupDone)) return; // Apply the selected model to the session so buildClaudeV2SessionOpts // picks up the correct model for warmup. @@ -5541,12 +5804,18 @@ export function createAgentChatService(args: { } packs.push( + { + scope: "feature", + label: "Feature", + description: "Feature-scoped export requires an explicit feature key", + available: Boolean(packService) + }, { scope: "mission", label: "Mission", - description: "Mission-scoped export requires an explicit mission selection and is not wired into this picker yet", - available: false - } + description: "Mission-scoped export requires an explicit mission selection", + available: Boolean(packService) + }, ); return packs; @@ -5721,9 +5990,41 @@ export function createAgentChatService(args: { threadId?: string; sdkSessionId?: string | null; }> => { + const managed = ensureManagedSession(sessionId); + const trimmed = text.trim(); + if (!trimmed.length) { + return { + sessionId, + provider: managed.session.provider, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + outputText: "", + ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), + ...(managed.runtime?.kind === "claude" ? { sdkSessionId: managed.runtime.sdkSessionId ?? null } : {}), + }; + } if (sessionTurnCollectors.has(sessionId)) { throw new Error(`Session '${sessionId}' already has an active background turn.`); } + const prepared = prepareSendMessage({ + sessionId, + text, + displayText, + attachments, + reasoningEffort, + executionMode, + }); + if (!prepared) { + return { + sessionId, + provider: managed.session.provider, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + outputText: "", + ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), + ...(managed.runtime?.kind === "claude" ? { sdkSessionId: managed.runtime.sdkSessionId ?? null } : {}), + }; + } const safeTimeoutMs = Math.max(15_000, Math.floor(timeoutMs)); return await new Promise((resolve, reject) => { @@ -5740,14 +6041,7 @@ export function createAgentChatService(args: { timeout, }); - void sendMessage({ - sessionId, - text, - displayText, - attachments, - reasoningEffort, - executionMode, - }).catch((error) => { + void executePreparedSendMessage(prepared).catch((error) => { clearTimeout(timeout); sessionTurnCollectors.delete(sessionId); reject(error instanceof Error ? error : new Error(String(error))); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.test.ts b/apps/desktop/src/main/services/cto/ctoStateService.test.ts index 87c9392f..3792a2fc 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.test.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.test.ts @@ -521,6 +521,7 @@ describe("ctoStateService", () => { }); expect(reloaded.getOnboardingState().completedSteps).toEqual(["identity"]); + expect(reloaded.getOnboardingState().completedAt).toBeTruthy(); expect(reloaded.getIdentity().personality).toBe("casual"); expect(reloaded.getIdentity().constraints).toEqual(["no force push", "write tests"]); expect(reloaded.getIdentity().systemPromptExtension).toBe("Stay calm under pressure."); @@ -542,12 +543,17 @@ describe("ctoStateService", () => { }); const preview = service.previewSystemPrompt(); - expect(preview.sections.map((section) => section.id)).toEqual(["doctrine", "personality", "memory"]); + expect(preview.sections.map((section) => section.id)).toEqual(["doctrine", "personality", "memory", "capabilities"]); expect(preview.sections[0]?.content).toContain("You are the CTO for the current project inside ADE."); expect(preview.sections[1]?.content).toContain("Operate as a strategic CTO."); expect(preview.sections[2]?.content).toContain("Immutable doctrine"); + expect(preview.sections[2]?.content).toContain("Use memoryUpdateCore only when the standing project brief changes"); + expect(preview.sections[2]?.content).toContain("Do not write ephemeral turn-by-turn status"); + expect(preview.sections[3]?.content).toContain("ADE operator capability manifest"); + expect(preview.sections[3]?.content).toContain("UI navigation is suggestion-only."); expect(preview.prompt).toContain("Immutable ADE doctrine"); expect(preview.prompt).toContain("Selected personality overlay"); + expect(preview.prompt).toContain("ADE operator capability manifest"); fixture.db.close(); }); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 712dd458..84715c74 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -55,6 +55,7 @@ type PersistedDoc = { const CTO_LONG_TERM_MEMORY_RELATIVE_PATH = ".ade/cto/MEMORY.md"; const CTO_CURRENT_CONTEXT_RELATIVE_PATH = ".ade/cto/CURRENT.md"; +const CTO_REQUIRED_ONBOARDING_STEPS = ["identity"] as const; const DURABLE_MEMORY_CATEGORY_ORDER: MemoryCategory[] = [ "decision", "convention", @@ -81,16 +82,34 @@ const IMMUTABLE_CTO_DOCTRINE = [ const CTO_MEMORY_OPERATING_MODEL = [ "ADE continuity model:", "1. Immutable doctrine: ADE always re-applies this CTO doctrine. It is not user-editable and it is not compacted away.", - `2. Long-term CTO brief: ${CTO_LONG_TERM_MEMORY_RELATIVE_PATH}. This stores project summary, conventions, preferences, focus, and standing notes.`, - `3. Current working context: ${CTO_CURRENT_CONTEXT_RELATIVE_PATH}. This carries recent sessions, worker activity, and active carry-forward context.`, + `2. Long-term CTO brief: ${CTO_LONG_TERM_MEMORY_RELATIVE_PATH}. ADE maintains this project-level state for summary, conventions, preferences, focus, and standing notes.`, + `3. Current working context: ${CTO_CURRENT_CONTEXT_RELATIVE_PATH}. ADE maintains this project-level state for recent sessions, worker activity, and active carry-forward context.`, "4. Durable searchable project memory. Use memorySearch to retrieve reusable context and memoryAdd to store stable lessons, decisions, patterns, gotchas, and preferences.", "", "Compaction and recovery rules:", "- Treat memory as mandatory operating infrastructure, not optional notes.", - "- Before non-trivial work, re-ground yourself in the long-term brief, current context, and durable memory.", - "- When the project brief changes, update Layer 2 with memoryUpdateCore.", - "- When you learn something reusable, store it with memoryAdd immediately.", - "- Distill important session context before compaction removes detail.", + "- Before non-trivial work, before asking the user to restate context, and before entering an unfamiliar subsystem, re-ground yourself in the long-term brief, current context, and durable memory.", + "- ADE already injects reconstructed CTO state into the session. Do not spend turns shell-reading relative .ade/cto files from the workspace unless an explicit absolute file path is required.", + "- Use memoryUpdateCore only when the standing project brief changes: summary, conventions, preferences, active focus, or standing notes.", + "- Use memoryAdd for reusable decisions, patterns, gotchas, and stable preferences that are likely to matter again.", + "- Do not write ephemeral turn-by-turn status, scratch notes, or one-off observations that can be recovered from the repo or recent chat history.", + "- Distill important session context before compaction removes detail, but persist only durable insights.", +].join("\n"); + +const CTO_CAPABILITY_MANIFEST = [ + "ADE operator capability manifest:", + "- Work chats: create, list, inspect, read transcripts, send follow-ups, interrupt, and end supervised sessions through ADE services.", + "- Lanes: list, inspect, create, and archive lanes, then hand back explicit navigation suggestions for opening them in ADE.", + "- Missions: create, inspect, launch, steer, read logs, export context, and route work into mission execution without exposing raw coordinator internals.", + "- Run / processes: inspect managed lane processes, start them, stop them, and read bounded log tails through ADE's process service.", + "- Pull requests and GitHub: inspect PR state, create PRs from lanes, update PR metadata, and post review-facing comments through stable PR services.", + "- Files and context: enumerate workspaces, read files, search text, and export bounded ADE context packs for project, lane, mission, conflict, plan, and feature scopes.", + "- Workers and Linear: supervise worker agents, trigger wakeups, inspect workflow runs, and route Linear issues into CTO, mission, or worker paths.", + "", + "Operating rules:", + "- Internal ADE actions run through service-backed tools even when no renderer click occurs.", + "- UI navigation is suggestion-only. When an action should be opened in ADE, return an explicit navigation suggestion instead of silently switching tabs.", + "- Treat ADE as your operating environment. Do not describe yourself as blocked on renderer button clicks when an internal tool can do the work.", ].join("\n"); function asStringArray(value: unknown): string[] { @@ -142,6 +161,11 @@ function normalizePersonalityPreset(value: unknown): CtoIdentity["personality"] : undefined; } +function hasCompletedRequiredOnboardingSteps(state: CtoOnboardingState | null | undefined): boolean { + const completedSteps = state?.completedSteps ?? []; + return CTO_REQUIRED_ONBOARDING_STEPS.every((stepId) => completedSteps.includes(stepId)); +} + function resolvePersonalityOverlay(identity: CtoIdentity): string { const presetId = identity.personality ?? "strategic"; if (presetId === "custom") { @@ -947,10 +971,12 @@ export function createCtoStateService(args: CtoStateServiceArgs) { const snapshot = getSnapshot(recentLimit); const sections: string[] = []; sections.push("CTO Memory Stack"); + sections.push("The CTO state below is already reconstructed by ADE for this session. Do not burn turns trying to rediscover it by shelling into relative .ade/cto paths."); sections.push(`- Layer 1 — runtime identity and operating doctrine. Hidden system instructions and identity.yaml keep you in the CTO role.`); sections.push(`- Layer 2 — long-term CTO brief at ${CTO_LONG_TERM_MEMORY_RELATIVE_PATH}. Update this layer with memoryUpdateCore when the project summary, conventions, preferences, focus, or standing notes change.`); sections.push(`- Layer 3 — current working context at ${CTO_CURRENT_CONTEXT_RELATIVE_PATH}. This layer carries active focus, recent sessions, worker activity, and daily logs through compaction.`); sections.push("- Layer 4 — searchable durable project memory. Use memorySearch before non-trivial work and memoryAdd for reusable decisions, conventions, patterns, gotchas, and stable preferences."); + sections.push("- Memory write policy: use memoryUpdateCore for standing brief changes, use memoryAdd for durable reusable lessons, and skip ephemeral status notes."); sections.push(""); sections.push("CTO Identity"); sections.push(`- Name: ${snapshot.identity.name}`); @@ -973,20 +999,8 @@ export function createCtoStateService(args: CtoStateServiceArgs) { return identity.onboardingState ?? { completedSteps: [] }; }; - const completeOnboardingStep = (stepId: string): CtoOnboardingState => { + const persistOnboardingState = (next: CtoOnboardingState): CtoOnboardingState => { const identity = getIdentity(); - const current = identity.onboardingState ?? { completedSteps: [] }; - if (current.completedSteps.includes(stepId)) return current; - - const next: CtoOnboardingState = { - ...current, - completedSteps: [...current.completedSteps, stepId], - }; - // If all 3 steps complete, mark completed - if (next.completedSteps.length >= 3 && !next.completedAt) { - next.completedAt = nowIso(); - } - const updated: CtoIdentity = { ...identity, onboardingState: next, @@ -999,35 +1013,33 @@ export function createCtoStateService(args: CtoStateServiceArgs) { return next; }; + const maybeMarkOnboardingComplete = (state: CtoOnboardingState): CtoOnboardingState => { + if (hasCompletedRequiredOnboardingSteps(state) && !state.completedAt) { + return { ...state, completedAt: nowIso() }; + } + return state; + }; + + const completeOnboardingStep = (stepId: string): CtoOnboardingState => { + const current = getOnboardingState(); + if (current.completedSteps.includes(stepId)) { + const patched = maybeMarkOnboardingComplete(current); + if (patched !== current) return persistOnboardingState(patched); + return current; + } + const next = maybeMarkOnboardingComplete({ + ...current, + completedSteps: [...current.completedSteps, stepId], + }); + return persistOnboardingState(next); + }; + const dismissOnboarding = (): CtoOnboardingState => { - const identity = getIdentity(); - const current = identity.onboardingState ?? { completedSteps: [] }; - const next: CtoOnboardingState = { ...current, dismissedAt: nowIso() }; - const updated: CtoIdentity = { - ...identity, - onboardingState: next, - version: identity.version + 1, - updatedAt: nowIso(), - }; - writeIdentityToFile(updated); - writeIdentityToDb(updated); - syncDerivedMemoryDocs(); - return next; + return persistOnboardingState({ ...getOnboardingState(), dismissedAt: nowIso() }); }; const resetOnboarding = (): CtoOnboardingState => { - const identity = getIdentity(); - const next: CtoOnboardingState = { completedSteps: [] }; - const updated: CtoIdentity = { - ...identity, - onboardingState: next, - version: identity.version + 1, - updatedAt: nowIso(), - }; - writeIdentityToFile(updated); - writeIdentityToDb(updated); - syncDerivedMemoryDocs(); - return next; + return persistOnboardingState({ completedSteps: [] }); }; /* ── Identity update (full patch) ── */ @@ -1075,6 +1087,11 @@ export function createCtoStateService(args: CtoStateServiceArgs) { title: "Memory and continuity model", content: CTO_MEMORY_OPERATING_MODEL, }, + { + id: "capabilities", + title: "ADE operator capability manifest", + content: CTO_CAPABILITY_MANIFEST, + }, ]; const prompt = [ diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index ef845ffb..7423998b 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -13,6 +13,7 @@ import type { CreateLaneArgs, DeleteLaneArgs, LaneIcon, + LaneStateSnapshotSummary, LaneStatus, LaneSummary, LaneType, @@ -55,6 +56,13 @@ type LaneRow = { status: string; }; +type LaneStateSnapshotRow = { + lane_id: string; + agent_summary_json: string | null; + mission_summary_json: string | null; + updated_at: string | null; +}; + const DEFAULT_LANE_STATUS: LaneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }; const LANE_LIST_CACHE_TTL_MS = 10_000; @@ -113,6 +121,18 @@ function parseLaneTags(raw: string | null): string[] { } } +function parseSummaryRecord(raw: string | null): Record | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + function toLaneSummary(args: { row: LaneRow; status: LaneStatus; @@ -721,19 +741,55 @@ export function createLaneService({ return await listLanes(args); }, - async refreshSnapshots(args: ListLanesArgs = {}): Promise<{ refreshedCount: number }> { + getStateSnapshot(laneId: string): LaneStateSnapshotSummary | null { + const row = db.get( + ` + select lane_id, agent_summary_json, mission_summary_json, updated_at + from lane_state_snapshots + where lane_id = ? + limit 1 + `, + [laneId], + ); + if (!row) return null; + return { + laneId: row.lane_id, + agentSummary: parseSummaryRecord(row.agent_summary_json), + missionSummary: parseSummaryRecord(row.mission_summary_json), + updatedAt: row.updated_at ?? null, + }; + }, + + listStateSnapshots(): LaneStateSnapshotSummary[] { + return db.all( + ` + select lane_id, agent_summary_json, mission_summary_json, updated_at + from lane_state_snapshots + `, + ).map((row) => ({ + laneId: row.lane_id, + agentSummary: parseSummaryRecord(row.agent_summary_json), + missionSummary: parseSummaryRecord(row.mission_summary_json), + updatedAt: row.updated_at ?? null, + })); + }, + + async refreshSnapshots(args: ListLanesArgs = {}): Promise<{ refreshedCount: number; lanes: LaneSummary[] }> { const summaries = await listLanes({ includeArchived: args.includeArchived ?? true, includeStatus: true, }); - return { refreshedCount: summaries.length }; + return { + refreshedCount: summaries.length, + lanes: summaries, + }; }, invalidateListCache(): void { invalidateLaneListCache(); }, - async create({ name, description, parentLaneId }: CreateLaneArgs): Promise { + async create({ name, description, parentLaneId, baseBranch }: CreateLaneArgs): Promise { if (parentLaneId) { const parent = getLaneRow(parentLaneId); if (!parent) throw new Error(`Parent lane not found: ${parentLaneId}`); @@ -759,27 +815,41 @@ export function createLaneService({ } } - const parentHeadSha = await getHeadSha(parent.worktree_path); + const trimmedBaseBranch = baseBranch?.trim() ?? ""; + const useCustomBase = parent.lane_type === "primary" && trimmedBaseBranch.length > 0; + const requestedBaseRef = useCustomBase ? trimmedBaseBranch : parent.branch_ref; + let parentHeadSha: string | null; + if (useCustomBase) { + const result = await runGit(["rev-parse", requestedBaseRef], { cwd: parent.worktree_path, timeoutMs: 10_000 }); + if (result.exitCode !== 0 || !result.stdout.trim().length) { + throw new Error(`Base branch not found on primary lane: ${requestedBaseRef}`); + } + parentHeadSha = result.stdout.trim(); + } else { + parentHeadSha = await getHeadSha(parent.worktree_path); + } if (!parentHeadSha) throw new Error(`Unable to resolve parent HEAD for lane ${parent.name}`); return await createWorktreeLane({ name, description, - baseRef: parent.branch_ref, + baseRef: requestedBaseRef, startPoint: parentHeadSha, parentLaneId: parent.id }); } // No parent specified: branch from defaultBaseRef. Resolve the exact SHA to avoid stale refs. - const headRes = await runGit(["rev-parse", defaultBaseRef], { cwd: projectRoot, timeoutMs: 10_000 }); + const trimmedBase = baseBranch?.trim() ?? ""; + const requestedBaseRef = trimmedBase.length > 0 ? trimmedBase : defaultBaseRef; + const headRes = await runGit(["rev-parse", requestedBaseRef], { cwd: projectRoot, timeoutMs: 10_000 }); const startPoint = headRes.exitCode === 0 && headRes.stdout.trim().length ? headRes.stdout.trim() - : defaultBaseRef; + : requestedBaseRef; return await createWorktreeLane({ name, description, - baseRef: defaultBaseRef, + baseRef: requestedBaseRef, startPoint, parentLaneId: null }); diff --git a/apps/desktop/src/main/services/memory/hybridSearchService.test.ts b/apps/desktop/src/main/services/memory/hybridSearchService.test.ts index 387d59ef..71b2cea2 100644 --- a/apps/desktop/src/main/services/memory/hybridSearchService.test.ts +++ b/apps/desktop/src/main/services/memory/hybridSearchService.test.ts @@ -1,8 +1,26 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { createRequire } from "node:module"; import { afterEach, describe, expect, it, vi } from "vitest"; import { openKvDb } from "../state/kvDb"; + +const require = createRequire(import.meta.url); +const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: new (path: string) => { exec: (sql: string) => void; close: () => void } }; + +function hasFts(): boolean { + const tmp = new DatabaseSync(":memory:"); + try { + tmp.exec("create virtual table _fts_probe using fts4(content)"); + return true; + } catch { + return false; + } finally { + tmp.close(); + } +} + +const ftsAvailable = hasFts(); import { DEFAULT_EMBEDDING_MODEL_ID, EXPECTED_EMBEDDING_DIMENSIONS } from "./embeddingService"; import { createEmbeddingWorkerService } from "./embeddingWorkerService"; import { @@ -193,7 +211,7 @@ function encodeMatchInfo(values: number[]): Uint8Array { } describe("hybridSearchService", () => { - it("keeps the FTS3 index in sync across insert, update, and delete", async () => { + it.skipIf(!ftsAvailable)("keeps the FTS3 index in sync across insert, update, and delete", async () => { const { db, memoryService, timestamp } = await createFixture({ attachQueueHook: false }); const memory = memoryService.addMemory({ @@ -274,7 +292,7 @@ describe("hybridSearchService", () => { expect(score).toBeCloseTo(2.8693045687, 5); }); - it("normalizes BM25 scores to [0, 1] and ranks denser keyword hits higher", async () => { + it.skipIf(!ftsAvailable)("normalizes BM25 scores to [0, 1] and ranks denser keyword hits higher", async () => { const { memoryService, worker, hybridSearchService } = await createFixture(); memoryService.addMemory({ @@ -360,7 +378,7 @@ describe("hybridSearchService", () => { expect(compositeScore).toBeCloseTo(0.816, 6); }); - it("re-ranks near-duplicates with MMR to favor diversity", async () => { + it.skipIf(!ftsAvailable)("re-ranks near-duplicates with MMR to favor diversity", async () => { const { memoryService, worker, hybridSearchService } = await createFixture(); const first = memoryService.addMemory({ @@ -398,7 +416,7 @@ describe("hybridSearchService", () => { expect(rankedIds.indexOf(diverse.id)).toBeLessThan(rankedIds.indexOf(duplicate.id)); }); - it("embeds the query string at search time", async () => { + it.skipIf(!ftsAvailable)("embeds the query string at search time", async () => { const { embeddingService, hybridSearchService } = await createFixture({ attachQueueHook: false }); await hybridSearchService.search({ @@ -410,7 +428,7 @@ describe("hybridSearchService", () => { expect(embeddingService.embed).toHaveBeenCalledWith("automobile"); }); - it("post-filters vector candidates by project, scope, and scope owner for isolation", async () => { + it.skipIf(!ftsAvailable)("post-filters vector candidates by project, scope, and scope owner for isolation", async () => { const { db, memoryService, worker, hybridSearchService } = await createFixture(); const projectMemory = memoryService.addMemory({ @@ -489,7 +507,7 @@ describe("hybridSearchService", () => { expect(hits.map((hit) => hit.memory.id)).toEqual([projectMemory.id]); }); - it("excludes archived entries from hybrid results even when embeddings exist", async () => { + it.skipIf(!ftsAvailable)("excludes archived entries from hybrid results even when embeddings exist", async () => { const { memoryService, worker, hybridSearchService } = await createFixture(); const archived = memoryService.addMemory({ @@ -519,7 +537,7 @@ describe("hybridSearchService", () => { expect(hits.map((hit) => hit.memory.id)).not.toContain(archived.id); }); - it("keeps lexical matches without embeddings by using BM25-only hybrid scoring", async () => { + it.skipIf(!ftsAvailable)("keeps lexical matches without embeddings by using BM25-only hybrid scoring", async () => { const { hybridSearchService, memoryService } = await createFixture({ attachQueueHook: false }); const memory = memoryService.addMemory({ @@ -581,7 +599,7 @@ describe("hybridSearchService", () => { expect(fallback.map((entry) => entry.compositeScore)).toEqual(lexical.map((entry) => entry.compositeScore)); }); - it("finds synonym matches through semantic search", async () => { + it.skipIf(!ftsAvailable)("finds synonym matches through semantic search", async () => { const { memoryService, worker } = await createFixture(); memoryService.addMemory({ @@ -599,7 +617,7 @@ describe("hybridSearchService", () => { expect(hits[0]!.content).toContain("car repair handbook"); }); - it("makes consolidated entries searchable after the embedding worker processes them", async () => { + it.skipIf(!ftsAvailable)("makes consolidated entries searchable after the embedding worker processes them", async () => { const { db, memoryService, worker } = await createFixture(); const memory = memoryService.writeMemory({ @@ -626,7 +644,7 @@ describe("hybridSearchService", () => { expect(hits.map((entry) => entry.id)).toContain(memory?.id); }); - it("supports the full write to queue to embed to search flow", async () => { + it.skipIf(!ftsAvailable)("supports the full write to queue to embed to search flow", async () => { const { db, memoryService, worker } = await createFixture(); const memory = memoryService.addMemory({ diff --git a/apps/desktop/src/main/services/memory/hybridSearchService.ts b/apps/desktop/src/main/services/memory/hybridSearchService.ts index 0d7f0bdd..80abda72 100644 --- a/apps/desktop/src/main/services/memory/hybridSearchService.ts +++ b/apps/desktop/src/main/services/memory/hybridSearchService.ts @@ -368,9 +368,9 @@ export function createHybridSearchService(opts: CreateHybridSearchServiceOpts) { ); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (/no such module: fts4/i.test(message) || /no such module: fts5/i.test(message) || /unable to use function matchinfo/i.test(message) || /unable to use function MATCH/i.test(message)) { - return []; - } + const isFtsMissing = /no such module: fts[45]/i.test(message) + || /unable to use function (matchinfo|MATCH)/i.test(message); + if (isFtsMissing) return []; throw error; } })(); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 40ab40c7..f735a45a 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -367,6 +367,9 @@ type PainPointCounter = Map; const RETROSPECTIVE_PATTERN_PROMOTION_THRESHOLD = 2; const RETROSPECTIVE_TREND_LOOKBACK_LIMIT = 50; const ISO_8601_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/; +const RETRY_BASE_MS = 10_000; +const RETRY_MULTIPLIER = 2; +const RETRY_MAX_MS = 300_000; function normalizeReflectionSignalType(value: string): OrchestratorReflectionEntry["signalType"] | null { if ( @@ -7310,7 +7313,7 @@ export function createOrchestratorService({ if (sessionId && managedLaunch && agentChatService) { void (async () => { try { - await agentChatService.sendMessage({ + await agentChatService.runSessionTurn({ sessionId, text: managedLaunch.prompt, displayText: managedLaunch.displayText, @@ -7643,11 +7646,7 @@ export function createOrchestratorService({ Number.isFinite(aiRetryBackoffRaw) && aiRetryBackoffRaw >= 0 ? Math.min(10 * 60_000, Math.floor(aiRetryBackoffRaw)) : null; - // Exponential backoff: base 10s, multiplier 2, cap 5min (300s). - // Priority: explicit caller backoff > AI-suggested > exponential default. - const RETRY_BASE_MS = 10_000; - const RETRY_MULTIPLIER = 2; - const RETRY_MAX_MS = 300_000; + // Exponential backoff with priority: caller backoff > AI-suggested > exponential default. const exponentialBackoffMs = Math.min( RETRY_MAX_MS, Math.floor(RETRY_BASE_MS * Math.pow(RETRY_MULTIPLIER, step.retryCount)) diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index a85db834..298785e1 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -118,6 +118,17 @@ type PullRequestRow = { updated_at: string; }; +type PullRequestSnapshotHydration = { + prId: string; + detail: PrDetail | null; + status: PrStatus | null; + checks: PrCheck[]; + reviews: PrReview[]; + comments: PrComment[]; + files: PrFile[]; + updatedAt: string | null; +}; + type IntegrationProposalRow = { id: string; source_lane_ids_json: string; @@ -630,7 +641,9 @@ export function createPrService({ [args.prId], ); - const encode = (next: unknown, fallback: string | null | undefined): string | null => { + // If the caller provides a value, serialize it. If undefined, keep the + // existing DB value. If explicitly null, store null. + const jsonOrFallback = (next: unknown, fallback: string | null | undefined): string | null => { if (next === undefined) return fallback ?? null; if (next == null) return null; return JSON.stringify(next); @@ -652,17 +665,59 @@ export function createPrService({ `, [ args.prId, - encode(args.detail, existing?.detail_json), - encode(args.status, existing?.status_json), - encode(args.checks, existing?.checks_json), - encode(args.reviews, existing?.reviews_json), - encode(args.comments, existing?.comments_json), - encode(args.files, existing?.files_json), + jsonOrFallback(args.detail, existing?.detail_json), + jsonOrFallback(args.status, existing?.status_json), + jsonOrFallback(args.checks, existing?.checks_json), + jsonOrFallback(args.reviews, existing?.reviews_json), + jsonOrFallback(args.comments, existing?.comments_json), + jsonOrFallback(args.files, existing?.files_json), args.updatedAt ?? nowIso(), ], ); }; + const decodeSnapshotJson = (raw: string | null): T | null => { + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + }; + + const listSnapshotRows = (args: { prId?: string } = {}): PullRequestSnapshotHydration[] => { + const rows = db.all<{ + pr_id: string; + detail_json: string | null; + status_json: string | null; + checks_json: string | null; + reviews_json: string | null; + comments_json: string | null; + files_json: string | null; + updated_at: string | null; + }>( + ` + select s.pr_id, s.detail_json, s.status_json, s.checks_json, s.reviews_json, s.comments_json, s.files_json, s.updated_at + from pull_request_snapshots s + join pull_requests p on p.id = s.pr_id and p.project_id = ? + ${args.prId ? "where s.pr_id = ?" : ""} + order by p.updated_at desc + `, + args.prId ? [projectId, args.prId] : [projectId], + ); + + return rows.map((row) => ({ + prId: row.pr_id, + detail: decodeSnapshotJson(row.detail_json), + status: decodeSnapshotJson(row.status_json), + checks: decodeSnapshotJson(row.checks_json) ?? [], + reviews: decodeSnapshotJson(row.reviews_json) ?? [], + comments: decodeSnapshotJson(row.comments_json) ?? [], + files: decodeSnapshotJson(row.files_json) ?? [], + updatedAt: row.updated_at, + })); + }; + const upsertRow = (summary: Omit & { projectId?: string }): void => { const now = nowIso(); const existing = getRowForLane(summary.laneId); @@ -3735,6 +3790,10 @@ export function createPrService({ return { refreshedCount: rows.length }; }, + listSnapshots(args: { prId?: string } = {}): PullRequestSnapshotHydration[] { + return listSnapshotRows(args); + }, + // ------------------------------------------------------------------ // PR Detail Overhaul Methods // ------------------------------------------------------------------ diff --git a/apps/desktop/src/main/services/state/crsqliteExtension.ts b/apps/desktop/src/main/services/state/crsqliteExtension.ts index a5b01195..1254501f 100644 --- a/apps/desktop/src/main/services/state/crsqliteExtension.ts +++ b/apps/desktop/src/main/services/state/crsqliteExtension.ts @@ -15,7 +15,7 @@ function platformArchDir(): string { return `${process.platform}-${process.arch}`; } -export function resolveCrsqliteExtensionPath(): string { +export function resolveCrsqliteExtensionPath(): string | null { const relativePath = path.join("vendor", "crsqlite", platformArchDir(), extensionFileName()); const candidates = [ process.resourcesPath ? path.join(process.resourcesPath, "app.asar.unpacked", relativePath) : null, @@ -32,5 +32,9 @@ export function resolveCrsqliteExtensionPath(): string { } } - throw new Error(`Unable to locate cr-sqlite extension for ${platformArchDir()}`); + return null; +} + +export function isCrsqliteAvailable(): boolean { + return resolveCrsqliteExtensionPath() != null; } diff --git a/apps/desktop/src/main/services/state/kvDb.sync.test.ts b/apps/desktop/src/main/services/state/kvDb.sync.test.ts index e6c02036..1498549c 100644 --- a/apps/desktop/src/main/services/state/kvDb.sync.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.sync.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; import { openKvDb } from "./kvDb"; +import { isCrsqliteAvailable } from "./crsqliteExtension"; const require = createRequire(import.meta.url); @@ -21,7 +22,7 @@ function makeDbPath(prefix: string): string { return path.join(root, ".ade", "kv.sqlite"); } -describe("kvDb sync foundation", () => { +describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => { it("persists a stable local site id and marks CRR tables", async () => { const dbPath = makeDbPath("ade-kvdb-sync-site-"); const db = await openKvDb(dbPath, createLogger() as any); @@ -259,11 +260,28 @@ describe("kvDb sync foundation", () => { const result = db2.sync.applyChanges(db1.sync.exportChangesSince(0)); expect(result.rebuiltFts).toBe(true); - const match = db2.get<{ count: number }>( - "select count(*) as count from unified_memories_fts where unified_memories_fts match ?", - ["ios"] + // FTS4/5 may not be available (e.g. node:sqlite without fts modules). + // When unavailable, the fallback plain table is used — verify with LIKE. + const isFts = Boolean( + db2.get<{ type: string }>( + "select type from sqlite_master where name = 'unified_memories_fts' and type = 'table' limit 1" + ) ); - expect(Number(match?.count ?? 0)).toBeGreaterThan(0); + if (isFts) { + // Fallback plain table — verify content was copied + const match = db2.get<{ count: number }>( + "select count(*) as count from unified_memories_fts where content like ?", + ["%ios%"] + ); + expect(Number(match?.count ?? 0)).toBeGreaterThan(0); + } else { + // Real FTS virtual table + const match = db2.get<{ count: number }>( + "select count(*) as count from unified_memories_fts where unified_memories_fts match ?", + ["ios"] + ); + expect(Number(match?.count ?? 0)).toBeGreaterThan(0); + } db1.close(); db2.close(); diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts new file mode 100644 index 00000000..9d06a040 --- /dev/null +++ b/apps/desktop/src/main/services/state/kvDb.test.ts @@ -0,0 +1,250 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { openKvDb } from "./kvDb"; +import { isCrsqliteAvailable } from "./crsqliteExtension"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as const; +} + +function makeProjectRoot(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.mkdirSync(path.join(root, ".ade", "artifacts"), { recursive: true }); + return root; +} + +function insertProjectGraph(db: Awaited>) { + const now = "2026-03-17T00:00:00.000Z"; + db.run( + `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) + values (?, ?, ?, ?, ?, ?)`, + ["project-1", "/repo/ade", "ADE", "main", now, now], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, + is_edit_protected, parent_lane_id, color, icon, tags_json, folder, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "lane-primary", + "project-1", + "Primary", + null, + "primary", + "main", + "main", + "/repo/ade", + null, + 1, + null, + null, + null, + null, + null, + "active", + now, + null, + ], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, + is_edit_protected, parent_lane_id, color, icon, tags_json, folder, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "lane-child", + "project-1", + "linear test", + null, + "worktree", + "main", + "ade/linear-test", + "/repo/ade/.ade/worktrees/linear-test", + null, + 0, + "lane-primary", + null, + null, + null, + null, + "active", + "2026-03-17T00:05:00.000Z", + null, + ], + ); + db.run( + `insert into lane_state_snapshots( + lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, mission_summary_json, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-primary", 0, 0, 0, 0, 0, null, null, now], + ); + db.run( + `insert into lane_state_snapshots( + lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, mission_summary_json, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-child", 1, 0, 1, 0, 0, null, null, "2026-03-17T00:05:00.000Z"], + ); +} + +function insertSessionAndPr(db: Awaited>) { + const now = "2026-03-17T00:10:00.000Z"; + db.run( + `insert into terminal_sessions( + id, lane_id, pty_id, tracked, goal, tool_type, pinned, title, started_at, ended_at, + exit_code, transcript_path, head_sha_start, head_sha_end, status, last_output_preview, + last_output_at, summary, resume_command + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "session-1", + "lane-child", + null, + 1, + "Ship W5", + "run-shell", + 0, + "npm test", + now, + null, + null, + "/tmp/session-1.log", + null, + null, + "running", + "Tests starting", + now, + null, + "npm test", + ], + ); + db.run( + `insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "pr-1", + "project-1", + "lane-child", + "arul", + "ade", + 42, + "https://github.com/arul/ade/pull/42", + "node-42", + "Fix mobile hydration", + "open", + "main", + "ade/linear-test", + "pending", + "requested", + 12, + 4, + now, + now, + now, + ], + ); + db.run( + `insert into pull_request_snapshots( + pr_id, detail_json, status_json, checks_json, reviews_json, comments_json, files_json, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "pr-1", + JSON.stringify({ + prId: "pr-1", + body: "Hydration fix", + assignees: [], + author: { login: "arul", avatarUrl: null }, + isDraft: false, + labels: [], + requestedReviewers: [], + milestone: null, + linkedIssues: [], + }), + JSON.stringify({ + prId: "pr-1", + state: "open", + checksStatus: "pending", + reviewStatus: "requested", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + }), + "[]", + "[]", + "[]", + "[]", + now, + ], + ); +} + +const activeDisposers: Array<() => Promise> = []; + +afterEach(async () => { + while (activeDisposers.length > 0) { + const dispose = activeDisposers.pop(); + if (dispose) await dispose(); + } +}); + +describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => { + it("backfills phone-critical tables whose rows predate CRR enablement", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-pre-crr-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const first = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(first); + insertSessionAndPr(first); + + first.run("drop table terminal_sessions__crsql_clock"); + first.run("drop table terminal_sessions__crsql_pks"); + first.run("drop table pull_request_snapshots__crsql_clock"); + first.run("drop table pull_request_snapshots__crsql_pks"); + first.close(); + + const reopened = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => reopened.close()); + + expect(reopened.get<{ count: number }>("select count(1) as count from terminal_sessions__crsql_pks")?.count).toBe(1); + expect(reopened.get<{ count: number }>("select count(1) as count from pull_request_snapshots__crsql_pks")?.count).toBe(1); + expect(reopened.get<{ count: number }>("select count(1) as count from terminal_sessions")?.count).toBe(1); + expect(reopened.get<{ count: number }>("select count(1) as count from pull_request_snapshots")?.count).toBe(1); + }); + + it("repairs divergent __crsql_pks counts without losing rows or indexes", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-mismatch-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const first = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(first); + insertSessionAndPr(first); + + first.run("delete from lanes__crsql_pks where __crsql_key = (select max(__crsql_key) from lanes__crsql_pks)"); + first.run( + "delete from lane_state_snapshots__crsql_pks where __crsql_key = (select max(__crsql_key) from lane_state_snapshots__crsql_pks)", + ); + first.run("delete from terminal_sessions__crsql_pks"); + first.run("delete from pull_requests__crsql_pks"); + first.close(); + + const reopened = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => reopened.close()); + + expect(reopened.get<{ count: number }>("select count(1) as count from lanes")?.count).toBe(2); + expect(reopened.get<{ count: number }>("select count(1) as count from lanes__crsql_pks")?.count).toBe(2); + expect(reopened.get<{ count: number }>("select count(1) as count from lane_state_snapshots__crsql_pks")?.count).toBe(2); + expect(reopened.get<{ count: number }>("select count(1) as count from terminal_sessions__crsql_pks")?.count).toBe(1); + expect(reopened.get<{ count: number }>("select count(1) as count from pull_requests__crsql_pks")?.count).toBe(1); + expect( + reopened.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'index' and name = 'idx_terminal_sessions_started_at' limit 1", + )?.present, + ).toBe(1); + }); +}); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 74035377..3b5834ad 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -244,12 +244,136 @@ function hasCrsqlMetadata(db: DatabaseSyncType): boolean { ); } -function ensureCrrTables(db: DatabaseSyncType): void { +const PHONE_CRITICAL_CRR_TABLES = [ + "lanes", + "lane_state_snapshots", + "terminal_sessions", + "pull_requests", + "pull_request_snapshots", +] as const; + +function countTableRows(db: DatabaseSyncType, tableName: string): number { + const row = getRow<{ count: number }>(db, `select count(1) as count from ${quoteIdentifier(tableName)}`); + return Number(row?.count ?? 0); +} + +function tableNeedsCrrRepair(db: DatabaseSyncType, tableName: string): { baseRowCount: number; pkRowCount: number } | null { + const baseRowCount = countTableRows(db, tableName); + if (baseRowCount <= 0) { + return null; + } + + const pksTable = `${tableName}__crsql_pks`; + if (!rawHasTable(db, pksTable)) { + return { baseRowCount, pkRowCount: 0 }; + } + + const pkRowCount = countTableRows(db, pksTable); + return pkRowCount === baseRowCount ? null : { baseRowCount, pkRowCount }; +} + +function rebuildCrrTableWithBackfill(db: DatabaseSyncType, tableName: string): void { + const tableRow = getRow<{ sql: string | null }>( + db, + "select sql from sqlite_master where type = 'table' and name = ? limit 1", + [tableName], + ); + const createSql = tableRow?.sql?.trim(); + if (!createSql) { + throw new Error(`Unable to repair CRR table ${tableName}: create SQL missing.`); + } + + const columns = allRows<{ name: string }>(db, `pragma table_info('${tableName.replace(/'/g, "''")}')`); + if (columns.length === 0) { + throw new Error(`Unable to repair CRR table ${tableName}: no columns found.`); + } + + const stageTable = `__ade_crr_stage_${tableName}`; + const columnsSql = columns.map((column) => quoteIdentifier(column.name)).join(", "); + const indexSqls = allRows<{ sql: string | null }>( + db, + "select sql from sqlite_master where type = 'index' and tbl_name = ? and sql is not null order by name asc", + [tableName], + ) + .map((row) => row.sql?.trim() ?? "") + .filter((sql) => sql.length > 0); + + runStatement(db, "pragma foreign_keys = off"); + runStatement(db, "begin"); + try { + runStatement( + db, + `create temp table ${quoteIdentifier(stageTable)} as select ${columnsSql} from ${quoteIdentifier(tableName)}`, + ); + runStatement(db, `drop table ${quoteIdentifier(tableName)}`); + if (rawHasTable(db, `${tableName}__crsql_clock`)) { + runStatement(db, `drop table ${quoteIdentifier(`${tableName}__crsql_clock`)}`); + } + if (rawHasTable(db, `${tableName}__crsql_pks`)) { + runStatement(db, `drop table ${quoteIdentifier(`${tableName}__crsql_pks`)}`); + } + runStatement(db, createSql); + for (const indexSql of indexSqls) { + runStatement(db, indexSql); + } + getRow(db, "select crsql_as_crr(?) as ok", [tableName]); + runStatement( + db, + `insert into ${quoteIdentifier(tableName)} (${columnsSql}) select ${columnsSql} from ${quoteIdentifier(stageTable)}`, + ); + runStatement(db, `drop table ${quoteIdentifier(stageTable)}`); + runStatement(db, "commit"); + } catch (error) { + runStatement(db, "rollback"); + throw error; + } finally { + runStatement(db, "pragma foreign_keys = on"); + } +} + +function ensureCrrTables(db: DatabaseSyncType, logger?: Logger): void { + const repairTargets = new Set(PHONE_CRITICAL_CRR_TABLES); for (const tableName of listEligibleCrrTables(db)) { if (rawHasTable(db, `${tableName}__crsql_clock`)) { + if (!repairTargets.has(tableName)) { + continue; + } + } else { + getRow(db, "select crsql_as_crr(?) as ok", [tableName]); + } + + if (!repairTargets.has(tableName)) { continue; } - getRow(db, "select crsql_as_crr(?) as ok", [tableName]); + + const mismatch = tableNeedsCrrRepair(db, tableName); + if (!mismatch) { + continue; + } + + logger?.warn("db.crr_integrity_mismatch", { + tableName, + baseRowCount: mismatch.baseRowCount, + pkRowCount: mismatch.pkRowCount, + }); + try { + rebuildCrrTableWithBackfill(db, tableName); + const remainingMismatch = tableNeedsCrrRepair(db, tableName); + if (remainingMismatch) { + logger?.warn("db.crr_integrity_repair_incomplete", { + tableName, + baseRowCount: remainingMismatch.baseRowCount, + pkRowCount: remainingMismatch.pkRowCount, + }); + } else { + logger?.info("db.crr_integrity_repaired", { tableName, rowCount: mismatch.baseRowCount }); + } + } catch (error) { + logger?.warn("db.crr_integrity_repair_failed", { + tableName, + error: error instanceof Error ? error.message : String(error), + }); + } } } @@ -2654,17 +2778,22 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { } +function loadCrsqlite(db: DatabaseSyncType, extensionPath: string): void { + db.enableLoadExtension(true); + db.loadExtension(extensionPath); +} + export async function openKvDb(dbPath: string, logger: Logger): Promise { const extensionPath = resolveCrsqliteExtensionPath(); + const hasCrsqlite = extensionPath != null; const desiredSiteId = ensureLocalSiteIdFile(dbPath); const existedBeforeOpen = fs.existsSync(dbPath); let db = openRawDatabase(dbPath); try { const hadCrsqlMetadata = hasCrsqlMetadata(db); - if (hadCrsqlMetadata) { - db.enableLoadExtension(true); - db.loadExtension(extensionPath); + if (hadCrsqlMetadata && hasCrsqlite) { + loadCrsqlite(db, extensionPath); } migrate({ @@ -2680,9 +2809,8 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { if (retrofitLegacyPrimaryKeyNotNullSchema(db)) { db.close(); db = openRawDatabase(dbPath); - if (hadCrsqlMetadata) { - db.enableLoadExtension(true); - db.loadExtension(extensionPath); + if (hadCrsqlMetadata && hasCrsqlite) { + loadCrsqlite(db, extensionPath); } migrate({ run: (sql: string, params: SqlValue[] = []) => { @@ -2691,19 +2819,21 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { }); } - if (!hadCrsqlMetadata) { - db.enableLoadExtension(true); - db.loadExtension(extensionPath); - } - ensureCrrTables(db); - forceSiteId(db, desiredSiteId); - - if (readCurrentSiteId(db) !== desiredSiteId) { - db.close(); - db = openRawDatabase(dbPath); - db.enableLoadExtension(true); - db.loadExtension(extensionPath); + if (hasCrsqlite) { + if (!hadCrsqlMetadata) { + loadCrsqlite(db, extensionPath); + } + ensureCrrTables(db, logger); forceSiteId(db, desiredSiteId); + + if (readCurrentSiteId(db) !== desiredSiteId) { + db.close(); + db = openRawDatabase(dbPath); + loadCrsqlite(db, extensionPath); + forceSiteId(db, desiredSiteId); + } + } else { + logger.warn("db.crsqlite_unavailable", { dbPath, reason: "extension not found for this platform" }); } } catch (err) { try { @@ -2726,7 +2856,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { const run = (sql: string, params: SqlValue[] = []) => { const alterTable = parseAlterTableTarget(sql); - if (alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) { + if (hasCrsqlite && alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) { getRow(db, "select crsql_begin_alter(?) as ok", [alterTable]); try { runStatement(db, sql, params); @@ -2747,13 +2877,17 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { return getRow(db, sql, params); }; + const crsqliteUnavailableError = () => new Error("cr-sqlite extension not available on this platform"); + const sync: AdeDbSyncApi = { getSiteId: () => desiredSiteId, getDbVersion: () => { + if (!hasCrsqlite) throw crsqliteUnavailableError(); const row = get<{ db_version: number }>("select crsql_db_version() as db_version"); return Number(row?.db_version ?? 0); }, exportChangesSince: (version: number) => { + if (!hasCrsqlite) throw crsqliteUnavailableError(); const rows = allRows<{ table_name: string; pk: unknown; @@ -2794,6 +2928,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { })); }, applyChanges: (changes: CrsqlChangeRow[]) => { + if (!hasCrsqlite) throw crsqliteUnavailableError(); let appliedCount = 0; const touchedTables = new Set(); runStatement(db, "begin"); diff --git a/apps/desktop/src/main/services/state/onConflictAudit.test.ts b/apps/desktop/src/main/services/state/onConflictAudit.test.ts index 791c3dab..1cea7ce9 100644 --- a/apps/desktop/src/main/services/state/onConflictAudit.test.ts +++ b/apps/desktop/src/main/services/state/onConflictAudit.test.ts @@ -55,6 +55,11 @@ const APPROVED_CONFLICT_TARGETS: ConflictTarget[] = [ table: "worker_agents", columns: "id", }, + { + file: "src/main/services/lanes/laneService.ts", + table: "lane_state_snapshots", + columns: "lane_id", + }, { file: "src/main/services/memory/proceduralLearningService.ts", table: "memory_procedure_details", @@ -95,6 +100,11 @@ const APPROVED_CONFLICT_TARGETS: ConflictTarget[] = [ table: "process_runtime", columns: "project_id,lane_id,process_key", }, + { + file: "src/main/services/prs/prService.ts", + table: "pull_request_snapshots", + columns: "pr_id", + }, { file: "src/main/services/prs/queueLandingService.ts", table: "queue_landing_state", diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.ts index bf28f71a..22a1b80b 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.ts @@ -78,7 +78,7 @@ function mapDeviceRow(row: DeviceRow | null): SyncDeviceRecord | null { updatedAt: String(row.updated_at), lastSeenAt: row.last_seen_at ? String(row.last_seen_at) : null, lastHost: row.last_host ? String(row.last_host) : null, - lastPort: typeof row.last_port === "number" ? row.last_port : row.last_port == null ? null : Number(row.last_port), + lastPort: row.last_port == null ? null : Number(row.last_port), tailscaleIp: row.tailscale_ip ? String(row.tailscale_ip) : null, ipAddresses: readJsonArray(row.ip_addresses_json), metadata: safeJsonParse>(row.metadata_json, {}), @@ -96,16 +96,38 @@ function mapClusterStateRow(row: ClusterStateRow | null): SyncClusterState | nul }; } -function listLocalIpAddresses(): string[] { +type LocalNetworkMetadata = { + lanIpAddresses: string[]; + tailscaleIp: string | null; +}; + +function isTailscaleAddress(ipAddress: string): boolean { + const parts = ipAddress.split("."); + if (parts.length !== 4) return false; + const octets = parts.map((part) => Number(part)); + if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; + return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127; +} + +function readLocalNetworkMetadata(): LocalNetworkMetadata { const interfaces = os.networkInterfaces(); - const values: string[] = []; - for (const entries of Object.values(interfaces)) { + const lan: string[] = []; + const tailscale: string[] = []; + for (const [interfaceName, entries] of Object.entries(interfaces)) { + const isLikelyTailscaleInterface = /tailscale|utun|tun/i.test(interfaceName); for (const entry of entries ?? []) { if (!entry || entry.internal || entry.family !== "IPv4") continue; - values.push(entry.address); + if (isLikelyTailscaleInterface || isTailscaleAddress(entry.address)) { + tailscale.push(entry.address); + } else { + lan.push(entry.address); + } } } - return uniqueStrings(values); + return { + lanIpAddresses: uniqueStrings(lan), + tailscaleIp: uniqueStrings(tailscale)[0] ?? null, + }; } function firstPreferredHost(ipAddresses: string[]): string { @@ -129,13 +151,14 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { const localSiteId = args.db.sync.getSiteId(); const getLocalDefaults = () => { - const ipAddresses = listLocalIpAddresses(); + const network = readLocalNetworkMetadata(); return { name: os.hostname(), platform: mapPlatform(process.platform), deviceType: "desktop" as SyncPeerDeviceType, - ipAddresses, - lastHost: firstPreferredHost(ipAddresses), + ipAddresses: network.lanIpAddresses, + tailscaleIp: network.tailscaleIp, + lastHost: firstPreferredHost(network.lanIpAddresses), }; }; @@ -203,17 +226,21 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { }; const ensureLocalDevice = (): SyncDeviceRecord => { + const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [localDeviceId])); const defaults = getLocalDefaults(); return upsertDeviceRecord({ deviceId: localDeviceId, siteId: localSiteId, - name: defaults.name, - platform: defaults.platform, - deviceType: defaults.deviceType, + name: existing?.name ?? defaults.name, + platform: existing?.platform ?? defaults.platform, + deviceType: existing?.deviceType ?? defaults.deviceType, lastSeenAt: nowIso(), - lastHost: defaults.lastHost, + lastHost: existing?.lastHost ?? defaults.lastHost, + lastPort: existing?.lastPort ?? null, + tailscaleIp: defaults.tailscaleIp, ipAddresses: defaults.ipAddresses, metadata: { + ...(existing?.metadata ?? {}), hostname: os.hostname(), }, }); @@ -303,6 +330,7 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { metadata?: Record; } = {}): SyncDeviceRecord => { const current = ensureLocalDevice(); + const network = readLocalNetworkMetadata(); return upsertDeviceRecord({ deviceId: current.deviceId, siteId: current.siteId, @@ -310,10 +338,10 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { platform: current.platform, deviceType: current.deviceType, lastSeenAt: argsIn.lastSeenAt ?? nowIso(), - lastHost: argsIn.lastHost ?? current.lastHost, + lastHost: argsIn.lastHost ?? current.lastHost ?? firstPreferredHost(network.lanIpAddresses), lastPort: argsIn.lastPort ?? current.lastPort, - tailscaleIp: current.tailscaleIp, - ipAddresses: current.ipAddresses, + tailscaleIp: network.tailscaleIp ?? current.tailscaleIp, + ipAddresses: network.lanIpAddresses.length > 0 ? network.lanIpAddresses : current.ipAddresses, metadata: { ...current.metadata, ...(argsIn.metadata ?? {}), diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index fff8acea..3b25ee55 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { openKvDb } from "../state/kvDb"; +import { isCrsqliteAvailable } from "../state/crsqliteExtension"; import { createSyncHostService } from "./syncHostService"; import { encodeSyncEnvelope, parseSyncEnvelope } from "./syncProtocol"; import type { ParsedSyncEnvelope } from "./syncProtocol"; @@ -197,7 +198,7 @@ afterEach(async () => { } }); -describe("syncHostService", () => { +describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { it("authenticates peers, relays CRDT changes, and rebroadcasts to other peers", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-brain-"), createLogger() as any); const dbA = await openKvDb(makeDbPath("ade-sync-peer-a-"), createLogger() as any); @@ -219,6 +220,59 @@ describe("syncHostService", () => { } as any, prService: { listAll: vi.fn().mockResolvedValue([]), + refresh: vi.fn().mockResolvedValue([ + { + id: "pr-1", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "arul", + repoName: "ade", + githubPrNumber: 42, + githubUrl: "https://github.com/arul/ade/pull/42", + githubNodeId: "node-42", + title: "Fix mobile hydration", + state: "open", + baseBranch: "main", + headBranch: "ade/mobile-hydration", + checksStatus: "pending", + reviewStatus: "requested", + additions: 12, + deletions: 4, + lastSyncedAt: "2026-03-17T00:10:00.000Z", + createdAt: "2026-03-17T00:10:00.000Z", + updatedAt: "2026-03-17T00:10:00.000Z", + }, + ]), + listSnapshots: vi.fn().mockReturnValue([ + { + prId: "pr-1", + detail: { + prId: "pr-1", + body: "Hydration fix", + assignees: [], + author: { login: "arul", avatarUrl: null }, + isDraft: false, + labels: [], + requestedReviewers: [], + milestone: null, + linkedIssues: [], + }, + status: { + prId: "pr-1", + state: "open", + checksStatus: "pending", + reviewStatus: "requested", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + }, + checks: [], + reviews: [], + comments: [], + files: [], + updatedAt: "2026-03-17T00:10:00.000Z", + }, + ]), getDetail: vi.fn(), getStatus: vi.fn(), getChecks: vi.fn(), @@ -294,7 +348,7 @@ describe("syncHostService", () => { expect(payload.changes.length).toBeGreaterThan(0); dbB.sync.applyChanges(payload.changes as any); expect(dbB.getJson<{ value: string }>("replicated-state")).toEqual({ value: "hello" }); - }); + }, 60_000); it("serves workspace file operations and artifact reads while blocking .git access", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-files-"), createLogger() as any); @@ -319,7 +373,67 @@ describe("syncHostService", () => { archive: vi.fn(), } as any, prService: { - listAll: vi.fn().mockResolvedValue([]), + listAll: vi.fn().mockResolvedValue([ + { + id: "pr-1", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "arul", + repoName: "ade", + githubPrNumber: 42, + githubUrl: "https://github.com/arul/ade/pull/42", + githubNodeId: "node-42", + title: "Fix mobile hydration", + state: "open", + baseBranch: "main", + headBranch: "ade/mobile-hydration", + checksStatus: "pending", + reviewStatus: "requested", + additions: 12, + deletions: 4, + lastSyncedAt: "2026-03-17T00:10:00.000Z", + createdAt: "2026-03-17T00:10:00.000Z", + updatedAt: "2026-03-17T00:10:00.000Z", + }, + ]), + refresh: vi.fn().mockResolvedValue([ + { + id: "pr-1", + }, + ]), + listSnapshots: vi.fn().mockReturnValue([ + { + prId: "pr-1", + detail: { + prId: "pr-1", + body: "Hydration fix", + assignees: [], + author: { login: "arul", avatarUrl: null }, + isDraft: false, + labels: [], + requestedReviewers: [], + milestone: null, + linkedIssues: [], + }, + status: { + prId: "pr-1", + state: "open", + checksStatus: "pending", + reviewStatus: "requested", + mergeableState: "unknown", + reviewDecision: null, + draft: false, + merged: false, + mergeCommitSha: null, + updatedAt: "2026-03-17T00:10:00.000Z", + }, + checks: [], + reviews: [], + comments: [], + files: [], + updatedAt: "2026-03-17T00:10:00.000Z", + }, + ]), getDetail: vi.fn(), getStatus: vi.fn(), getChecks: vi.fn(), @@ -428,7 +542,67 @@ describe("syncHostService", () => { archive: vi.fn(), } as any, prService: { - listAll: vi.fn().mockResolvedValue([]), + listAll: vi.fn().mockResolvedValue([ + { + id: "pr-1", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "arul", + repoName: "ade", + githubPrNumber: 42, + githubUrl: "https://github.com/arul/ade/pull/42", + githubNodeId: "node-42", + title: "Fix mobile hydration", + state: "open", + baseBranch: "main", + headBranch: "ade/mobile-hydration", + checksStatus: "pending", + reviewStatus: "requested", + additions: 12, + deletions: 4, + lastSyncedAt: "2026-03-17T00:10:00.000Z", + createdAt: "2026-03-17T00:10:00.000Z", + updatedAt: "2026-03-17T00:10:00.000Z", + }, + ]), + refresh: vi.fn().mockResolvedValue([ + { + id: "pr-1", + }, + ]), + listSnapshots: vi.fn().mockReturnValue([ + { + prId: "pr-1", + detail: { + prId: "pr-1", + body: "Hydration fix", + assignees: [], + author: { login: "arul", avatarUrl: null }, + isDraft: false, + labels: [], + requestedReviewers: [], + milestone: null, + linkedIssues: [], + }, + status: { + prId: "pr-1", + state: "open", + checksStatus: "pending", + reviewStatus: "requested", + mergeableState: "unknown", + reviewDecision: null, + draft: false, + merged: false, + mergeCommitSha: null, + updatedAt: "2026-03-17T00:10:00.000Z", + }, + checks: [], + reviews: [], + comments: [], + files: [], + updatedAt: "2026-03-17T00:10:00.000Z", + }, + ]), getDetail: vi.fn(), getStatus: vi.fn(), getChecks: vi.fn(), @@ -441,7 +615,30 @@ describe("syncHostService", () => { requestReviewers: vi.fn(), } as any, sessionService: { - list: () => [], + list: () => [ + { + id: "session-1", + laneId: "lane-1", + laneName: "Primary", + ptyId: "pty-1", + tracked: true, + pinned: false, + goal: "Run tests", + toolType: "run-shell", + title: "npm test", + status: "running", + startedAt: "2026-03-17T00:10:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: path.join(projectRoot, ".ade", "transcripts", "session-1.log"), + headShaStart: null, + headShaEnd: null, + lastOutputPreview: "prior output", + summary: null, + runtimeState: "running", + resumeCommand: "npm test", + }, + ], get: () => ({ id: "session-1", transcriptPath: path.join(projectRoot, ".ade", "transcripts", "session-1.log"), @@ -520,6 +717,45 @@ describe("syncHostService", () => { expect((result.payload as { ok: boolean; result: { sessionId: string } }).result.sessionId).toBe("session-1"); expect(createSpy).toHaveBeenCalledTimes(1); + client.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "cmd-work-list", + payload: { + commandId: "cmd-work-list", + action: "work.listSessions", + args: {}, + }, + })); + const workListAck = await client.queue.next("command_ack"); + expect((workListAck.payload as { accepted: boolean }).accepted).toBe(true); + const workListResult = await client.queue.next("command_result"); + const workSessions = (workListResult.payload as { ok: boolean; result: Array<{ id: string }> }).result; + expect(workSessions.map((entry) => entry.id)).toEqual(["session-1"]); + + client.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "cmd-pr-refresh", + payload: { + commandId: "cmd-pr-refresh", + action: "prs.refresh", + args: {}, + }, + })); + const prRefreshAck = await client.queue.next("command_ack"); + expect((prRefreshAck.payload as { accepted: boolean }).accepted).toBe(true); + const prRefreshResult = await client.queue.next("command_result"); + const prRefreshPayload = prRefreshResult.payload as { + ok: boolean; + result: { + refreshedCount: number; + prs: Array<{ id: string }>; + snapshots: Array<{ prId: string }>; + }; + }; + expect(prRefreshPayload.result.refreshedCount).toBe(1); + expect(prRefreshPayload.result.prs.map((entry) => entry.id)).toEqual(["pr-1"]); + expect(prRefreshPayload.result.snapshots.map((entry) => entry.prId)).toEqual(["pr-1"]); + client.ws.send(encodeSyncEnvelope({ type: "command", requestId: "cmd-unsupported", @@ -640,9 +876,67 @@ describe("syncHostService", () => { }, })); const helloOk = await authQueue.next("hello_ok"); - const helloPayload = helloOk.payload as { features: { pairingAuth: { enabled: boolean } } }; + const helloPayload = helloOk.payload as { + features: { + pairingAuth: { enabled: boolean }; + commandRouting: { + supportedActions: string[]; + actions: Array<{ action: string; policy: { queueable?: boolean; viewerAllowed: boolean } }>; + }; + }; + }; expect(helloPayload.features.pairingAuth.enabled).toBe(true); - authWs.close(); - await new Promise((resolve) => authWs.once("close", resolve)); + expect(helloPayload.features.commandRouting.supportedActions).toContain("lanes.getDetail"); + expect(helloPayload.features.commandRouting.supportedActions).toContain("lanes.rename"); + const getDetailDescriptor = helloPayload.features.commandRouting.actions.find( + (entry) => entry.action === "lanes.getDetail", + ); + expect(getDetailDescriptor?.policy.viewerAllowed).toBe(true); + expect(getDetailDescriptor?.policy.queueable).toBeUndefined(); + expect(helloPayload.features.commandRouting.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "lanes.rename", + policy: expect.objectContaining({ viewerAllowed: true, queueable: true }), + }), + ]), + ); + expect(host.getPeerStates().map((peer) => peer.deviceId)).toContain("ios-phone-1"); + + host.revokePairedDevice("ios-phone-1"); + if (authWs.readyState !== WebSocket.CLOSED) { + await new Promise((resolve) => authWs.once("close", resolve)); + } + await waitFor(() => !host.getPeerStates().some((peer) => peer.deviceId === "ios-phone-1")); + const revokedWs = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve, reject) => { + revokedWs.once("open", () => resolve()); + revokedWs.once("error", reject); + }); + const revokedQueue = createMessageQueue(revokedWs); + revokedWs.send(encodeSyncEnvelope({ + type: "hello", + requestId: "hello-revoked", + payload: { + peer: { + deviceId: "ios-phone-1", + deviceName: "Arul iPhone", + platform: "iOS", + deviceType: "phone", + siteId: "ios-site-1", + dbVersion: 0, + }, + auth: { + kind: "paired", + deviceId: "ios-phone-1", + secret: pairingPayload.secret, + }, + }, + })); + const revokedHello = await revokedQueue.next("hello_error"); + const revokedPayload = revokedHello.payload as { code: string; message: string }; + expect(revokedPayload.code).toBe("auth_failed"); + revokedWs.close(); + await new Promise((resolve) => revokedWs.once("close", resolve)); }); }); diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index 14cbefe4..60567fcd 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { randomBytes } from "node:crypto"; +import { Bonjour, type Service as BonjourService } from "bonjour-service"; import { WebSocketServer, WebSocket, type RawData } from "ws"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import type { @@ -29,8 +30,18 @@ import type { SyncTerminalSnapshotPayload, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; +import type { createAgentChatService } from "../chat/agentChatService"; +import type { createProjectConfigService } from "../config/projectConfigService"; +import type { createConflictService } from "../conflicts/conflictService"; import type { createFileService } from "../files/fileService"; +import type { createDiffService } from "../diffs/diffService"; +import type { createGitOperationsService } from "../git/gitOperationsService"; +import type { createAutoRebaseService } from "../lanes/autoRebaseService"; +import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; import type { createLaneService } from "../lanes/laneService"; +import type { createLaneTemplateService } from "../lanes/laneTemplateService"; +import type { createPortAllocationService } from "../lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; import type { createPtyService } from "../pty/ptyService"; import type { createPrService } from "../prs/prService"; import type { createSessionService } from "../sessions/sessionService"; @@ -45,11 +56,14 @@ const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000; const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; +const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; type PeerState = { ws: WebSocket; metadata: SyncPeerMetadata | null; authenticated: boolean; + authKind: "bootstrap" | "paired" | null; + pairedDeviceId: string | null; connectedAt: string; lastSeenAt: string; lastAppliedAt: string | null; @@ -67,9 +81,19 @@ type SyncHostServiceArgs = { projectRoot: string; fileService: ReturnType; laneService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; prService: ReturnType; sessionService: ReturnType; ptyService: ReturnType; + agentChatService?: ReturnType; + projectConfigService?: ReturnType; + portAllocationService?: ReturnType; + laneEnvironmentService?: ReturnType; + laneTemplateService?: ReturnType; + rebaseSuggestionService?: ReturnType; + autoRebaseService?: ReturnType; computerUseArtifactBrokerService: ReturnType; bootstrapTokenPath?: string; port?: number; @@ -240,6 +264,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) { prService: args.prService, ptyService: args.ptyService, sessionService: args.sessionService, + fileService: args.fileService, + gitService: args.gitService, + diffService: args.diffService, + conflictService: args.conflictService, + agentChatService: args.agentChatService, + projectConfigService: args.projectConfigService, + portAllocationService: args.portAllocationService, + laneEnvironmentService: args.laneEnvironmentService, + laneTemplateService: args.laneTemplateService, + rebaseSuggestionService: args.rebaseSuggestionService, + autoRebaseService: args.autoRebaseService, logger: args.logger, }); const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS)); @@ -267,6 +302,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); let disposed = false; + let bonjourInstance: Bonjour | null = null; + let bonjourAnnouncement: BonjourService | null = null; + let bonjourPort: number | null = null; let lastBroadcastAt: string | null = null; const startedAtMs = Date.now(); const pollTimer = setInterval(() => { @@ -299,6 +337,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ws, metadata: null, authenticated: false, + authKind: null, + pairedDeviceId: null, connectedAt: nowIso(), lastSeenAt: nowIso(), lastAppliedAt: null, @@ -331,6 +371,53 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }); + const publishLanDiscovery = (port: number): void => { + if (disposed) return; + if (bonjourAnnouncement && bonjourPort === port) return; + const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; + const hostName = localDevice?.name ?? os.hostname(); + const ipAddresses = (localDevice?.ipAddresses ?? []).filter((value) => value.trim().length > 0); + const txt = { + version: "1", + deviceId: localDevice?.deviceId ?? "", + siteId: localDevice?.siteId ?? "", + deviceName: hostName, + port: String(port), + host: localDevice?.lastHost ?? "", + addresses: ipAddresses.join(","), + tailscaleIp: localDevice?.tailscaleIp ?? "", + }; + if (!bonjourInstance) { + bonjourInstance = new Bonjour(undefined, (error: unknown) => { + args.logger.warn("sync_host.discovery_error", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + if (bonjourAnnouncement) { + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + } + bonjourPort = port; + bonjourAnnouncement = bonjourInstance.publish({ + name: `ADE Sync ${hostName} ${port}`, + type: SYNC_MDNS_SERVICE_TYPE, + protocol: "tcp", + port, + txt, + disableIPv6: true, + }); + bonjourAnnouncement.on("error", (error: unknown) => { + args.logger.warn("sync_host.discovery_publish_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + function send(ws: WebSocket, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void { ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); } @@ -653,6 +740,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.authenticated = true; peer.metadata = hello.peer; + const auth = hello.auth ?? { kind: "bootstrap", token: "" }; + peer.authKind = auth.kind; + peer.pairedDeviceId = auth.kind === "paired" ? auth.deviceId : null; peer.lastKnownServerDbVersion = Math.max(0, Math.floor(hello.peer.dbVersion)); args.deviceRegistryService?.upsertPeerMetadata(hello.peer, { lastSeenAt: nowIso(), @@ -676,6 +766,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { commandRouting: { mode: "allowlisted", supportedActions: remoteCommandService.getSupportedActions(), + actions: remoteCommandService.getDescriptors(), }, }, }, envelope.requestId); @@ -761,13 +852,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) { async waitUntilListening(): Promise { if (server.address()) { const address = server.address(); - return typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; + const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; + publishLanDiscovery(port); + return port; } await new Promise((resolve) => { server.once("listening", () => resolve()); }); const address = server.address(); - return typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; + const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; + publishLanDiscovery(port); + return port; }, getPort(): number | null { @@ -785,6 +880,24 @@ export function createSyncHostService(args: SyncHostServiceArgs) { revokePairedDevice(deviceId: string): void { pairingStore.revoke(deviceId); + let revokedConnectedPeer = false; + for (const peer of peers) { + if (!peer.authenticated || peer.authKind !== "paired" || peer.pairedDeviceId !== deviceId) continue; + revokedConnectedPeer = true; + peer.authenticated = false; + peer.metadata = null; + peer.authKind = null; + peer.pairedDeviceId = null; + try { + peer.ws.close(4003, "Pairing revoked"); + } catch { + // ignore close failures + } + } + if (revokedConnectedPeer) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } }, getPeerStates(): SyncPeerConnectionState[] { @@ -840,6 +953,22 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } server.close(() => resolve()); }); + if (bonjourAnnouncement) { + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + } + if (bonjourInstance) { + try { + bonjourInstance.destroy(); + } catch { + // ignore cleanup failures + } + bonjourInstance = null; + } }, }; } diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 19d60c70..46e98886 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1,19 +1,69 @@ import type { + AgentChatCreateArgs, + AgentChatGetSummaryArgs, + AgentChatListArgs, + AgentChatProvider, + AgentChatSendArgs, + ApplyLaneTemplateArgs, ArchiveLaneArgs, + AttachLaneArgs, ClosePrArgs, + CreateChildLaneArgs, CreateLaneArgs, CreatePrFromLaneArgs, + DeleteLaneArgs, + GetDiffChangesArgs, + GetFileDiffArgs, + GitBatchFileActionArgs, + GitCherryPickArgs, + GitCommitArgs, + GitFileActionArgs, + GitGenerateCommitMessageArgs, + GitGetCommitMessageArgs, + GitListBranchesArgs, + GitListCommitFilesArgs, + GitPushArgs, + GitRevertArgs, + GitStashPushArgs, + GitStashRefArgs, + GitSyncArgs, LandPrArgs, + LaneEnvInitConfig, + LaneEnvInitProgress, + LaneDetailPayload, + LaneListSnapshot, + LaneOverlayOverrides, + LaneStateSnapshotSummary, ListLanesArgs, ListSessionsArgs, + RebasePushArgs, + RebaseStartArgs, + RenameLaneArgs, + ReopenPrArgs, + ReparentLaneArgs, RequestPrReviewersArgs, SyncCommandPayload, SyncRemoteCommandAction, + SyncRemoteCommandDescriptor, SyncRemoteCommandPolicy, SyncRunQuickCommandArgs, TerminalToolType, + UpdateLaneAppearanceArgs, + WriteTextAtomicArgs, } from "../../../shared/types"; +import type { createAgentChatService } from "../chat/agentChatService"; +import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; +import type { createProjectConfigService } from "../config/projectConfigService"; +import type { createConflictService } from "../conflicts/conflictService"; +import type { createDiffService } from "../diffs/diffService"; +import type { createFileService } from "../files/fileService"; +import type { createGitOperationsService } from "../git/gitOperationsService"; +import type { createAutoRebaseService } from "../lanes/autoRebaseService"; +import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; import type { createLaneService } from "../lanes/laneService"; +import type { createLaneTemplateService } from "../lanes/laneTemplateService"; +import type { createPortAllocationService } from "../lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; import type { Logger } from "../logging/logger"; import type { createPrService } from "../prs/prService"; import type { createPtyService } from "../pty/ptyService"; @@ -24,11 +74,22 @@ type SyncRemoteCommandServiceArgs = { prService: ReturnType; ptyService: ReturnType; sessionService: ReturnType; + fileService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; + agentChatService?: ReturnType; + projectConfigService?: ReturnType; + portAllocationService?: ReturnType | null; + laneEnvironmentService?: ReturnType | null; + laneTemplateService?: ReturnType | null; + rebaseSuggestionService?: ReturnType | null; + autoRebaseService?: ReturnType | null; logger: Logger; }; type RegisteredRemoteCommand = { - policy: SyncRemoteCommandPolicy; + descriptor: SyncRemoteCommandDescriptor; handler: (args: Record) => Promise; }; @@ -53,6 +114,23 @@ function asStringArray(value: unknown): string[] { return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)); } +function requireString(value: unknown, message: string): string { + const parsed = asTrimmedString(value); + if (!parsed) throw new Error(message); + return parsed; +} + +function requireStringArray(value: unknown, message: string): string[] { + const parsed = asStringArray(value); + if (parsed.length === 0) throw new Error(message); + return parsed; +} + +function requireService(value: T | null | undefined, message: string): T { + if (value == null) throw new Error(message); + return value; +} + function parseListLanesArgs(value: Record): ListLanesArgs { return { includeArchived: asOptionalBoolean(value.includeArchived), @@ -61,21 +139,98 @@ function parseListLanesArgs(value: Record): ListLanesArgs { } function parseCreateLaneArgs(value: Record): CreateLaneArgs { - const name = asTrimmedString(value.name); - if (!name) throw new Error("lanes.create requires name."); - const description = asTrimmedString(value.description); - const parentLaneId = asTrimmedString(value.parentLaneId); return { - name, - ...(description ? { description } : {}), - ...(parentLaneId ? { parentLaneId } : {}), + name: requireString(value.name, "lanes.create requires name."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + ...(asTrimmedString(value.parentLaneId) ? { parentLaneId: asTrimmedString(value.parentLaneId)! } : {}), + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + }; +} + +function parseCreateChildLaneArgs(value: Record): CreateChildLaneArgs { + return { + name: requireString(value.name, "lanes.createChild requires name."), + parentLaneId: requireString(value.parentLaneId, "lanes.createChild requires parentLaneId."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + ...(asTrimmedString(value.folder) ? { folder: asTrimmedString(value.folder)! } : {}), }; } -function parseArchiveLaneArgs(value: Record): ArchiveLaneArgs { - const laneId = asTrimmedString(value.laneId); - if (!laneId) throw new Error("lanes.archive requires laneId."); - return { laneId }; +function parseAttachLaneArgs(value: Record): AttachLaneArgs { + return { + name: requireString(value.name, "lanes.attach requires name."), + attachedPath: requireString(value.attachedPath, "lanes.attach requires attachedPath."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + }; +} + +function parseArchiveLaneArgs(value: Record, action: string): ArchiveLaneArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + }; +} + +function parseDeleteLaneArgs(value: Record): DeleteLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.delete requires laneId."), + deleteBranch: asOptionalBoolean(value.deleteBranch), + deleteRemoteBranch: asOptionalBoolean(value.deleteRemoteBranch), + ...(asTrimmedString(value.remoteName) ? { remoteName: asTrimmedString(value.remoteName)! } : {}), + force: asOptionalBoolean(value.force), + }; +} + +function parseRenameLaneArgs(value: Record): RenameLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.rename requires laneId."), + name: requireString(value.name, "lanes.rename requires name."), + }; +} + +function parseReparentLaneArgs(value: Record): ReparentLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.reparent requires laneId."), + newParentLaneId: requireString(value.newParentLaneId, "lanes.reparent requires newParentLaneId."), + }; +} + +function parseUpdateLaneAppearanceArgs(value: Record): UpdateLaneAppearanceArgs { + const parsed: UpdateLaneAppearanceArgs = { + laneId: requireString(value.laneId, "lanes.updateAppearance requires laneId."), + }; + if ("color" in value) { + parsed.color = value.color == null ? null : asTrimmedString(value.color) ?? null; + } + if ("icon" in value) { + parsed.icon = value.icon == null ? null : (asTrimmedString(value.icon) as UpdateLaneAppearanceArgs["icon"]); + } + if ("tags" in value) { + parsed.tags = value.tags == null ? null : asStringArray(value.tags); + } + return parsed; +} + +function parseRebaseStartArgs(value: Record): RebaseStartArgs { + return { + laneId: requireString(value.laneId, "lanes.rebaseStart requires laneId."), + ...(asTrimmedString(value.scope) ? { scope: value.scope as RebaseStartArgs["scope"] } : {}), + ...(asTrimmedString(value.pushMode) ? { pushMode: value.pushMode as RebaseStartArgs["pushMode"] } : {}), + ...(asTrimmedString(value.actor) ? { actor: asTrimmedString(value.actor)! } : {}), + ...(asTrimmedString(value.reason) ? { reason: asTrimmedString(value.reason)! } : {}), + }; +} + +function parseRebasePushArgs(value: Record): RebasePushArgs { + return { + runId: requireString(value.runId, "lanes.rebasePush requires runId."), + laneIds: requireStringArray(value.laneIds, "lanes.rebasePush requires laneIds."), + }; +} + +function parseRunIdArgs(value: Record, action: string): { runId: string } { + return { + runId: requireString(value.runId, `${action} requires runId.`), + }; } function parseListSessionsArgs(value: Record): ListSessionsArgs { @@ -90,26 +245,217 @@ function parseListSessionsArgs(value: Record): ListSessionsArgs } function parseQuickCommandArgs(value: Record): SyncRunQuickCommandArgs { - const laneId = asTrimmedString(value.laneId); - const title = asTrimmedString(value.title); + const laneId = requireString(value.laneId, "work.runQuickCommand requires laneId."); + const title = requireString(value.title, "work.runQuickCommand requires title."); + const toolType = asTrimmedString(value.toolType); const startupCommand = asTrimmedString(value.startupCommand); - if (!laneId || !title || !startupCommand) { - throw new Error("work.runQuickCommand requires laneId, title, and startupCommand."); + if (!startupCommand && toolType !== "shell") { + throw new Error("work.runQuickCommand requires startupCommand unless toolType is shell."); } return { laneId, title, - startupCommand, + ...(startupCommand ? { startupCommand } : {}), cols: asOptionalNumber(value.cols), rows: asOptionalNumber(value.rows), - toolType: asTrimmedString(value.toolType), + toolType, + tracked: asOptionalBoolean(value.tracked), + }; +} + +function parseCloseSessionArgs(value: Record): { sessionId: string } { + return { + sessionId: requireString(value.sessionId, "work.closeSession requires sessionId."), + }; +} + +function parseAgentChatListArgs(value: Record): AgentChatListArgs { + return { + ...(asTrimmedString(value.laneId) ? { laneId: asTrimmedString(value.laneId)! } : {}), + includeAutomation: asOptionalBoolean(value.includeAutomation), + }; +} + +function parseAgentChatGetSummaryArgs(value: Record): AgentChatGetSummaryArgs { + return { + sessionId: requireString(value.sessionId, "chat.getSummary requires sessionId."), + }; +} + +function parseAgentChatCreateArgs(value: Record): AgentChatCreateArgs { + return { + laneId: requireString(value.laneId, "chat.create requires laneId."), + provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatCreateArgs["provider"], + model: asTrimmedString(value.model) ?? "", + ...(asTrimmedString(value.modelId) ? { modelId: asTrimmedString(value.modelId)! } : {}), + ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), + }; +} + +function parseAgentChatSendArgs(value: Record): AgentChatSendArgs { + return { + sessionId: requireString(value.sessionId, "chat.send requires sessionId."), + text: requireString(value.text, "chat.send requires text."), + }; +} + +function parseGetTranscriptArgs(value: Record): { + sessionId: string; + limit?: number; + maxChars?: number; +} { + return { + sessionId: requireString(value.sessionId, "chat.getTranscript requires sessionId."), + limit: asOptionalNumber(value.limit), + maxChars: asOptionalNumber(value.maxChars), + }; +} + +function parseGitFileActionArgs(value: Record, action: string): GitFileActionArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + path: requireString(value.path, `${action} requires path.`), + }; +} + +function parseGitBatchFileActionArgs(value: Record, action: string): GitBatchFileActionArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + paths: requireStringArray(value.paths, `${action} requires paths.`), + }; +} + +function parseWriteTextAtomicArgs(value: Record): WriteTextAtomicArgs { + return { + laneId: requireString(value.laneId, "files.writeTextAtomic requires laneId."), + path: requireString(value.path, "files.writeTextAtomic requires path."), + text: typeof value.text === "string" ? value.text : "", + }; +} + +function parseGitCommitArgs(value: Record): GitCommitArgs { + return { + laneId: requireString(value.laneId, "git.commit requires laneId."), + message: requireString(value.message, "git.commit requires message."), + amend: asOptionalBoolean(value.amend), + }; +} + +function parseGitGenerateCommitMessageArgs(value: Record): GitGenerateCommitMessageArgs { + return { + laneId: requireString(value.laneId, "git.generateCommitMessage requires laneId."), + amend: asOptionalBoolean(value.amend), + }; +} + +function parseGitListRecentCommitsArgs(value: Record): { laneId: string; limit?: number } { + return { + laneId: requireString(value.laneId, "git.listRecentCommits requires laneId."), + limit: asOptionalNumber(value.limit), + }; +} + +function parseGitListCommitFilesArgs(value: Record): GitListCommitFilesArgs { + return { + laneId: requireString(value.laneId, "git.listCommitFiles requires laneId."), + commitSha: requireString(value.commitSha, "git.listCommitFiles requires commitSha."), + }; +} + +function parseGitGetCommitMessageArgs(value: Record): GitGetCommitMessageArgs { + return { + laneId: requireString(value.laneId, "git.getCommitMessage requires laneId."), + commitSha: requireString(value.commitSha, "git.getCommitMessage requires commitSha."), + }; +} + +function parseGitRevertArgs(value: Record): GitRevertArgs { + return { + laneId: requireString(value.laneId, "git.revertCommit requires laneId."), + commitSha: requireString(value.commitSha, "git.revertCommit requires commitSha."), + }; +} + +function parseGitCherryPickArgs(value: Record): GitCherryPickArgs { + return { + laneId: requireString(value.laneId, "git.cherryPickCommit requires laneId."), + commitSha: requireString(value.commitSha, "git.cherryPickCommit requires commitSha."), + }; +} + +function parseGitStashPushArgs(value: Record): GitStashPushArgs { + return { + laneId: requireString(value.laneId, "git.stashPush requires laneId."), + ...(asTrimmedString(value.message) ? { message: asTrimmedString(value.message)! } : {}), + includeUntracked: asOptionalBoolean(value.includeUntracked), + }; +} + +function parseGitStashRefArgs(value: Record, action: string): GitStashRefArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + stashRef: requireString(value.stashRef, `${action} requires stashRef.`), + }; +} + +function parseGitSyncArgs(value: Record): GitSyncArgs { + return { + laneId: requireString(value.laneId, "git.sync requires laneId."), + ...(asTrimmedString(value.mode) ? { mode: value.mode as GitSyncArgs["mode"] } : {}), + ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), + }; +} + +function parseGitPushArgs(value: Record): GitPushArgs { + return { + laneId: requireString(value.laneId, "git.push requires laneId."), + forceWithLease: asOptionalBoolean(value.forceWithLease), + }; +} + +function parseGetDiffChangesArgs(value: Record): GetDiffChangesArgs { + return { + laneId: requireString(value.laneId, "git.getChanges requires laneId."), + }; +} + +function parseGetFileDiffArgs(value: Record): GetFileDiffArgs { + return { + laneId: requireString(value.laneId, "git.getFile requires laneId."), + path: requireString(value.path, "git.getFile requires path."), + mode: requireString(value.mode, "git.getFile requires mode.") as GetFileDiffArgs["mode"], + ...(asTrimmedString(value.compareRef) ? { compareRef: asTrimmedString(value.compareRef)! } : {}), + ...(asTrimmedString(value.compareTo) ? { compareTo: value.compareTo as GetFileDiffArgs["compareTo"] } : {}), + }; +} + +function parseGitListBranchesArgs(value: Record): GitListBranchesArgs { + return { + laneId: requireString(value.laneId, "git.listBranches requires laneId."), + }; +} + +function parseGitCheckoutBranchArgs(value: Record): { laneId: string; branchName: string } { + return { + laneId: requireString(value.laneId, "git.checkoutBranch requires laneId."), + branchName: requireString(value.branchName, "git.checkoutBranch requires branchName."), + }; +} + +function parseConflictLaneArgs(value: Record, action: string): { laneId: string } { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + }; +} + +function parseChatModelsArgs(value: Record): { provider: AgentChatProvider } { + return { + provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatProvider, }; } function requirePrId(value: Record, action: string): string { - const prId = asTrimmedString(value.prId); - if (!prId) throw new Error(`${action} requires prId.`); - return prId; + return requireString(value.prId, `${action} requires prId.`); } function parseCreatePrArgs(value: Record): CreatePrFromLaneArgs { @@ -139,13 +485,18 @@ function parseLandPrArgs(value: Record): LandPrArgs { } function parseClosePrArgs(value: Record): ClosePrArgs { - const prId = requirePrId(value, "prs.close"); return { - prId, + prId: requirePrId(value, "prs.close"), ...(typeof value.comment === "string" ? { comment: value.comment } : {}), }; } +function parseReopenPrArgs(value: Record): ReopenPrArgs { + return { + prId: requirePrId(value, "prs.reopen"), + }; +} + function parseRequestReviewersArgs(value: Record): RequestPrReviewersArgs { const prId = requirePrId(value, "prs.requestReviewers"); const reviewers = asStringArray(value.reviewers); @@ -153,119 +504,542 @@ function parseRequestReviewersArgs(value: Record): RequestPrRev return { prId, reviewers }; } -export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArgs) { - const registry = new Map([ - ["lanes.list", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.laneService.list(parseListLanesArgs(payload)), - }], - ["lanes.refreshSnapshots", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.laneService.refreshSnapshots(parseListLanesArgs(payload)), - }], - ["lanes.create", { - policy: { viewerAllowed: true, queueable: true }, - handler: async (payload) => args.laneService.create(parseCreateLaneArgs(payload)), - }], - ["lanes.archive", { - policy: { viewerAllowed: true, queueable: true }, - handler: async (payload) => { - await args.laneService.archive(parseArchiveLaneArgs(payload)); - return { ok: true }; - }, - }], - ["lanes.unarchive", { - policy: { viewerAllowed: true, queueable: true }, - handler: async (payload) => { - await args.laneService.unarchive(parseArchiveLaneArgs(payload)); - return { ok: true }; - }, - }], - ["work.listSessions", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.sessionService.list(parseListSessionsArgs(payload)), - }], - ["work.runQuickCommand", { - policy: { viewerAllowed: true, queueable: true }, - handler: async (payload) => { - const parsed = parseQuickCommandArgs(payload); - return await args.ptyService.create({ - laneId: parsed.laneId, - title: parsed.title, - startupCommand: parsed.startupCommand, - tracked: true, - cols: parsed.cols ?? 120, - rows: parsed.rows ?? 36, - toolType: (parsed.toolType ?? "run-shell") as TerminalToolType, - }); - }, - }], - ["prs.list", { - policy: { viewerAllowed: true }, - handler: async () => args.prService.listAll(), - }], - ["prs.refresh", { - policy: { viewerAllowed: true }, - handler: async (payload) => { - const prId = asTrimmedString(payload.prId); - return await args.prService.refreshSnapshots(prId ? { prId } : {}); - }, - }], - ["prs.getDetail", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.prService.getDetail(requirePrId(payload, "prs.getDetail")), - }], - ["prs.getStatus", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.prService.getStatus(requirePrId(payload, "prs.getStatus")), - }], - ["prs.getChecks", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.prService.getChecks(requirePrId(payload, "prs.getChecks")), - }], - ["prs.getReviews", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.prService.getReviews(requirePrId(payload, "prs.getReviews")), - }], - ["prs.getComments", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.prService.getComments(requirePrId(payload, "prs.getComments")), - }], - ["prs.getFiles", { - policy: { viewerAllowed: true }, - handler: async (payload) => args.prService.getFiles(requirePrId(payload, "prs.getFiles")), - }], - ["prs.createFromLane", { - policy: { viewerAllowed: true, queueable: true }, - handler: async (payload) => args.prService.createFromLane(parseCreatePrArgs(payload)), - }], - ["prs.land", { - policy: { viewerAllowed: true, queueable: true }, - handler: async (payload) => args.prService.land(parseLandPrArgs(payload)), - }], - ["prs.close", { - policy: { viewerAllowed: true, queueable: true }, - handler: async (payload) => { - await args.prService.closePr(parseClosePrArgs(payload)); - return { ok: true }; - }, - }], - ["prs.requestReviewers", { - policy: { viewerAllowed: true, queueable: true }, - handler: async (payload) => { - await args.prService.requestReviewers(parseRequestReviewersArgs(payload)); - return { ok: true }; - }, - }], +function mergeLaneDockerConfig( + current: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, + next: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, +) { + if (!current && !next) return undefined; + if (!current) return next ? { ...next, ...(next.services ? { services: [...next.services] } : {}) } : undefined; + if (!next) return { ...current, ...(current.services ? { services: [...current.services] } : {}) }; + return { + ...current, + ...next, + ...(next.services != null + ? { services: [...next.services] } + : current.services != null + ? { services: [...current.services] } + : {}), + }; +} + +function mergeLaneEnvInitConfig( + current: LaneEnvInitConfig | undefined, + next: LaneEnvInitConfig | undefined, +): LaneEnvInitConfig | undefined { + if (!current && !next) return undefined; + if (!current) { + return next + ? { + ...(next.envFiles ? { envFiles: [...next.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, next.docker) ? { docker: mergeLaneDockerConfig(undefined, next.docker) } : {}), + ...(next.dependencies ? { dependencies: [...next.dependencies] } : {}), + ...(next.mountPoints ? { mountPoints: [...next.mountPoints] } : {}), + ...(next.copyPaths ? { copyPaths: [...next.copyPaths] } : {}), + } + : undefined; + } + if (!next) { + return { + ...(current.envFiles ? { envFiles: [...current.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, current.docker) ? { docker: mergeLaneDockerConfig(undefined, current.docker) } : {}), + ...(current.dependencies ? { dependencies: [...current.dependencies] } : {}), + ...(current.mountPoints ? { mountPoints: [...current.mountPoints] } : {}), + ...(current.copyPaths ? { copyPaths: [...current.copyPaths] } : {}), + }; + } + return { + envFiles: [...(current.envFiles ?? []), ...(next.envFiles ?? [])], + ...(mergeLaneDockerConfig(current.docker, next.docker) ? { docker: mergeLaneDockerConfig(current.docker, next.docker) } : {}), + dependencies: [...(current.dependencies ?? []), ...(next.dependencies ?? [])], + mountPoints: [...(current.mountPoints ?? []), ...(next.mountPoints ?? [])], + copyPaths: [...(current.copyPaths ?? []), ...(next.copyPaths ?? [])], + }; +} + +function mergeLaneOverrides(base: LaneOverlayOverrides, next: Partial): LaneOverlayOverrides { + return { + ...base, + ...next, + ...(base.env || next.env ? { env: { ...(base.env ?? {}), ...(next.env ?? {}) } } : {}), + ...(base.processIds || next.processIds ? { processIds: [...(next.processIds ?? base.processIds ?? [])] } : {}), + ...(base.testSuiteIds || next.testSuiteIds ? { testSuiteIds: [...(next.testSuiteIds ?? base.testSuiteIds ?? [])] } : {}), + ...(mergeLaneEnvInitConfig(base.envInit, next.envInit) ? { envInit: mergeLaneEnvInitConfig(base.envInit, next.envInit) } : {}), + }; +} + +function applyLeaseToOverrides( + overrides: LaneOverlayOverrides, + lease: { status: string; rangeStart: number; rangeEnd: number } | null, +): LaneOverlayOverrides { + if (!lease || lease.status !== "active" || overrides.portRange) { + return { ...overrides }; + } + return { + ...overrides, + portRange: { start: lease.rangeStart, end: lease.rangeEnd }, + }; +} + +async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) { + const projectConfigService = requireService(args.projectConfigService, "Project config service not available."); + const lanes = await args.laneService.list({ includeStatus: false }); + const lane = lanes.find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + + const config = projectConfigService.getEffective(); + const overlayOverrides = matchLaneOverlayPolicies(lane, config.laneOverlayPolicies ?? []); + const lease = args.portAllocationService?.getLease(lane.id) ?? null; + const overrides = applyLeaseToOverrides(overlayOverrides, lease); + const envInitConfig = args.laneEnvironmentService?.resolveEnvInitConfig(config.laneEnvInit, overrides); + + return { + lane, + overrides, + envInitConfig, + }; +} + +async function resolveChatCreateArgs( + service: ReturnType, + payload: AgentChatCreateArgs, +): Promise { + if (payload.model.trim().length > 0) return payload; + const available = await service.getAvailableModels({ provider: payload.provider }); + const chosen = available[0]; + if (!chosen) { + throw new Error(`No configured ${payload.provider} chat model is available on the host.`); + } + return { + ...payload, + model: chosen.id, + ...(!payload.modelId && chosen.modelId ? { modelId: chosen.modelId } : {}), + }; +} + +function sessionStatusBucket(argsIn: { + status: string; + lastOutputPreview: string | null | undefined; + runtimeState?: string | null; +}): "running" | "awaiting-input" | "ended" { + if (argsIn.status === "running") { + if (argsIn.runtimeState === "waiting-input") return "awaiting-input"; + const preview = argsIn.lastOutputPreview ?? ""; + if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { + return "awaiting-input"; + } + if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { + return "awaiting-input"; + } + return "running"; + } + return "ended"; +} + +function summarizeLaneRuntime( + laneId: string, + sessions: Array<{ + laneId: string; + status: string; + lastOutputPreview: string | null; + runtimeState?: string | null; + }>, +): LaneListSnapshot["runtime"] { + let runningCount = 0; + let awaitingInputCount = 0; + let endedCount = 0; + let sessionCount = 0; + for (const session of sessions) { + if (session.laneId !== laneId) continue; + sessionCount += 1; + const bucket = sessionStatusBucket(session); + if (bucket === "running") runningCount += 1; + else if (bucket === "awaiting-input") awaitingInputCount += 1; + else endedCount += 1; + } + const bucket = runningCount > 0 + ? "running" + : awaitingInputCount > 0 + ? "awaiting-input" + : endedCount > 0 + ? "ended" + : "none"; + return { + bucket, + runningCount, + awaitingInputCount, + endedCount, + sessionCount, + }; +} + +async function buildLaneListSnapshots( + args: SyncRemoteCommandServiceArgs, + lanes: Awaited["list"]>>, +): Promise { + const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + Promise.resolve(args.sessionService.list({ limit: 500 })), + Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), + Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), + Promise.resolve(args.laneService.listStateSnapshots()), + args.conflictService?.getBatchAssessment().catch(() => null) ?? Promise.resolve(null), + ]); + + const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); + const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); + const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); + const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); + + return lanes.map((lane) => ({ + lane, + runtime: summarizeLaneRuntime(lane.id, sessions), + rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, + autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, + conflictStatus: conflictByLaneId.get(lane.id) ?? null, + stateSnapshot: stateByLaneId.get(lane.id) ?? null, + adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + })); +} + +async function buildLaneDetailPayload(args: SyncRemoteCommandServiceArgs, laneId: string): Promise { + const lane = (await args.laneService.list({ includeArchived: true, includeStatus: true })).find((entry) => entry.id === laneId) ?? null; + if (!lane) throw new Error(`Lane not found: ${laneId}`); + + const [ + stackChain, + children, + sessions, + chatSessions, + rebaseSuggestions, + autoRebaseStatuses, + stateSnapshot, + recentCommits, + diffChanges, + stashes, + syncStatus, + conflictState, + conflictStatus, + overlaps, + envInitProgress, + ] = await Promise.all([ + args.laneService.getStackChain(laneId), + args.laneService.getChildren(laneId), + Promise.resolve(args.sessionService.list({ laneId, limit: 200 })), + args.agentChatService?.listSessions(laneId, { includeAutomation: true }) ?? Promise.resolve([]), + Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), + Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), + Promise.resolve(args.laneService.getStateSnapshot(laneId)), + args.gitService?.listRecentCommits({ laneId, limit: 20 }) ?? Promise.resolve([]), + args.diffService?.getChanges(laneId).catch(() => null) ?? Promise.resolve(null), + args.gitService?.listStashes({ laneId }) ?? Promise.resolve([]), + args.gitService?.getSyncStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.gitService?.getConflictState({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.conflictService?.getLaneStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.conflictService?.listOverlaps({ laneId }).catch(() => []) ?? Promise.resolve([]), + Promise.resolve(args.laneEnvironmentService?.getProgress(laneId) ?? null), ]); + return { + lane, + runtime: summarizeLaneRuntime(laneId, sessions), + stackChain, + children, + stateSnapshot: stateSnapshot as LaneStateSnapshotSummary | null, + rebaseSuggestion: rebaseSuggestions.find((entry) => entry.laneId === laneId) ?? null, + autoRebaseStatus: autoRebaseStatuses.find((entry) => entry.laneId === laneId) ?? null, + conflictStatus, + overlaps, + syncStatus, + conflictState, + recentCommits, + diffChanges, + stashes, + envInitProgress, + sessions, + chatSessions, + }; +} + +export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArgs) { + const registry = new Map(); + + const register = ( + action: SyncRemoteCommandAction, + policy: SyncRemoteCommandPolicy, + handler: (payload: Record) => Promise, + ) => { + registry.set(action, { + descriptor: { action, policy }, + handler, + }); + }; + + register("lanes.list", { viewerAllowed: true }, async (payload) => args.laneService.list(parseListLanesArgs(payload))); + register("lanes.refreshSnapshots", { viewerAllowed: true }, async (payload) => { + const refreshed = await args.laneService.refreshSnapshots(parseListLanesArgs(payload)); + return { + ...refreshed, + snapshots: await buildLaneListSnapshots(args, refreshed.lanes), + }; + }); + register("lanes.getDetail", { viewerAllowed: true }, async (payload) => + buildLaneDetailPayload(args, requireString(payload.laneId, "lanes.getDetail requires laneId."))); + register("lanes.create", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.create(parseCreateLaneArgs(payload))); + register("lanes.createChild", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.createChild(parseCreateChildLaneArgs(payload))); + register("lanes.attach", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.attach(parseAttachLaneArgs(payload))); + register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.adoptAttached({ laneId: requireString(payload.laneId, "lanes.adoptAttached requires laneId.") })); + register("lanes.rename", { viewerAllowed: true, queueable: true }, async (payload) => { + args.laneService.rename(parseRenameLaneArgs(payload)); + return { ok: true }; + }); + register("lanes.reparent", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.reparent(parseReparentLaneArgs(payload))); + register("lanes.updateAppearance", { viewerAllowed: true, queueable: true }, async (payload) => { + args.laneService.updateAppearance(parseUpdateLaneAppearanceArgs(payload)); + return { ok: true }; + }); + register("lanes.archive", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.archive(parseArchiveLaneArgs(payload, "lanes.archive")); + return { ok: true }; + }); + register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive")); + return { ok: true }; + }); + register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.delete(parseDeleteLaneArgs(payload)); + return { ok: true }; + }); + register("lanes.getStackChain", { viewerAllowed: true }, async (payload) => + args.laneService.getStackChain(requireString(payload.laneId, "lanes.getStackChain requires laneId."))); + register("lanes.getChildren", { viewerAllowed: true }, async (payload) => + args.laneService.getChildren(requireString(payload.laneId, "lanes.getChildren requires laneId."))); + register("lanes.rebaseStart", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseStart(parseRebaseStartArgs(payload))); + register("lanes.rebasePush", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebasePush(parseRebasePushArgs(payload))); + register("lanes.rebaseRollback", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseRollback(parseRunIdArgs(payload, "lanes.rebaseRollback"))); + register("lanes.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseAbort(parseRunIdArgs(payload, "lanes.rebaseAbort"))); + register("lanes.listRebaseSuggestions", { viewerAllowed: true }, async () => args.rebaseSuggestionService?.listSuggestions() ?? []); + register("lanes.dismissRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.rebaseSuggestionService) return { ok: true }; + await args.rebaseSuggestionService.dismiss({ laneId: requireString(payload.laneId, "lanes.dismissRebaseSuggestion requires laneId.") }); + return { ok: true }; + }); + register("lanes.deferRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.rebaseSuggestionService) return { ok: true }; + await args.rebaseSuggestionService.defer({ + laneId: requireString(payload.laneId, "lanes.deferRebaseSuggestion requires laneId."), + minutes: asOptionalNumber(payload.minutes) ?? 60, + }); + return { ok: true }; + }); + register("lanes.listAutoRebaseStatuses", { viewerAllowed: true }, async () => args.autoRebaseService?.listStatuses() ?? []); + register("lanes.listTemplates", { viewerAllowed: true }, async () => args.laneTemplateService?.listTemplates() ?? []); + register("lanes.getDefaultTemplate", { viewerAllowed: true }, async () => args.laneTemplateService?.getDefaultTemplateId() ?? null); + register("lanes.getEnvStatus", { viewerAllowed: true }, async (payload) => args.laneEnvironmentService?.getProgress(requireString(payload.laneId, "lanes.getEnvStatus requires laneId.")) ?? null); + register("lanes.initEnv", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); + const laneId = requireString(payload.laneId, "lanes.initEnv requires laneId."); + const context = await resolveLaneOverlayContext(args, laneId); + if (!context.envInitConfig) { + const now = new Date().toISOString(); + return { + laneId, + steps: [], + startedAt: now, + completedAt: now, + overallStatus: "completed", + } satisfies LaneEnvInitProgress; + } + return await laneEnvironmentService.initLaneEnvironment(context.lane, context.envInitConfig, context.overrides); + }); + register("lanes.applyTemplate", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneTemplateService = requireService(args.laneTemplateService, "Lane template service not available."); + const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); + const parsed = { + laneId: requireString(payload.laneId, "lanes.applyTemplate requires laneId."), + templateId: requireString(payload.templateId, "lanes.applyTemplate requires templateId."), + } satisfies ApplyLaneTemplateArgs; + const context = await resolveLaneOverlayContext(args, parsed.laneId); + const template = laneTemplateService.getTemplate(parsed.templateId); + if (!template) throw new Error(`Template not found: ${parsed.templateId}`); + const templateEnvInit = laneTemplateService.resolveTemplateAsEnvInit(template); + const mergedOverrides = mergeLaneOverrides(context.overrides, { + ...(template.envVars ? { env: template.envVars } : {}), + ...(!context.overrides.portRange && template.portRange ? { portRange: template.portRange } : {}), + envInit: templateEnvInit, + }); + const mergedEnvInitConfig = mergeLaneEnvInitConfig(context.envInitConfig, templateEnvInit) ?? templateEnvInit; + return await laneEnvironmentService.initLaneEnvironment(context.lane, mergedEnvInitConfig, mergedOverrides); + }); + + register("work.listSessions", { viewerAllowed: true }, async (payload) => args.sessionService.list(parseListSessionsArgs(payload))); + register("work.runQuickCommand", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseQuickCommandArgs(payload); + return await args.ptyService.create({ + laneId: parsed.laneId, + title: parsed.title, + ...(parsed.toolType === "shell" || !parsed.startupCommand ? {} : { startupCommand: parsed.startupCommand }), + tracked: parsed.tracked ?? true, + cols: parsed.cols ?? 120, + rows: parsed.rows ?? 36, + toolType: (parsed.toolType ?? "run-shell") as TerminalToolType, + }); + }); + register("work.closeSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const { sessionId } = parseCloseSessionArgs(payload); + const session = args.sessionService.get(sessionId); + if (session?.ptyId) { + await args.ptyService.dispose({ ptyId: session.ptyId, sessionId }); + } + return { ok: true }; + }); + + register("chat.listSessions", { viewerAllowed: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const parsed = parseAgentChatListArgs(payload); + return agentChatService.listSessions(parsed.laneId, { includeAutomation: parsed.includeAutomation }); + }); + register("chat.getSummary", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); + register("chat.getTranscript", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getChatTranscript(parseGetTranscriptArgs(payload))); + register("chat.create", { viewerAllowed: true, queueable: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const parsed = parseAgentChatCreateArgs(payload); + return await agentChatService.createSession(await resolveChatCreateArgs(agentChatService, parsed)); + }); + register("chat.send", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").sendMessage(parseAgentChatSendArgs(payload)); + return { ok: true }; + }); + register("chat.models", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload))); + + register("git.getChanges", { viewerAllowed: true }, async (payload) => + requireService(args.diffService, "Diff service not available.").getChanges(parseGetDiffChangesArgs(payload).laneId)); + register("git.getFile", { viewerAllowed: true }, async (payload) => { + const diffService = requireService(args.diffService, "Diff service not available."); + const parsed = parseGetFileDiffArgs(payload); + return await diffService.getFileDiff({ + laneId: parsed.laneId, + filePath: parsed.path, + mode: parsed.mode, + compareRef: parsed.compareRef, + compareTo: parsed.compareTo, + }); + }); + register("files.writeTextAtomic", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseWriteTextAtomicArgs(payload); + args.fileService.writeTextAtomic({ laneId: parsed.laneId, relPath: parsed.path, text: parsed.text }); + return { ok: true }; + }); + register("git.stageFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stageFile(parseGitFileActionArgs(payload, "git.stageFile"))); + register("git.stageAll", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stageAll(parseGitBatchFileActionArgs(payload, "git.stageAll"))); + register("git.unstageFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").unstageFile(parseGitFileActionArgs(payload, "git.unstageFile"))); + register("git.unstageAll", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").unstageAll(parseGitBatchFileActionArgs(payload, "git.unstageAll"))); + register("git.discardFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").discardFile(parseGitFileActionArgs(payload, "git.discardFile"))); + register("git.restoreStagedFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").restoreStagedFile(parseGitFileActionArgs(payload, "git.restoreStagedFile"))); + register("git.commit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").commit(parseGitCommitArgs(payload))); + register("git.generateCommitMessage", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").generateCommitMessage(parseGitGenerateCommitMessageArgs(payload))); + register("git.listRecentCommits", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listRecentCommits(parseGitListRecentCommitsArgs(payload))); + register("git.listCommitFiles", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listCommitFiles(parseGitListCommitFilesArgs(payload))); + register("git.getCommitMessage", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getCommitMessage(parseGitGetCommitMessageArgs(payload))); + register("git.revertCommit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").revertCommit(parseGitRevertArgs(payload))); + register("git.cherryPickCommit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").cherryPickCommit(parseGitCherryPickArgs(payload))); + register("git.stashPush", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashPush(parseGitStashPushArgs(payload))); + register("git.stashList", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listStashes(parseConflictLaneArgs(payload, "git.stashList"))); + register("git.stashApply", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashApply(parseGitStashRefArgs(payload, "git.stashApply"))); + register("git.stashPop", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashPop(parseGitStashRefArgs(payload, "git.stashPop"))); + register("git.stashDrop", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashDrop(parseGitStashRefArgs(payload, "git.stashDrop"))); + register("git.fetch", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").fetch(parseConflictLaneArgs(payload, "git.fetch"))); + register("git.pull", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").pull(parseConflictLaneArgs(payload, "git.pull"))); + register("git.getSyncStatus", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getSyncStatus(parseConflictLaneArgs(payload, "git.getSyncStatus"))); + register("git.sync", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").sync(parseGitSyncArgs(payload))); + register("git.push", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").push(parseGitPushArgs(payload))); + register("git.getConflictState", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getConflictState(parseConflictLaneArgs(payload, "git.getConflictState"))); + register("git.rebaseContinue", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").rebaseContinue(parseConflictLaneArgs(payload, "git.rebaseContinue"))); + register("git.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").rebaseAbort(parseConflictLaneArgs(payload, "git.rebaseAbort"))); + register("git.listBranches", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listBranches(parseGitListBranchesArgs(payload))); + register("git.checkoutBranch", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").checkoutBranch(parseGitCheckoutBranchArgs(payload))); + + register("conflicts.getLaneStatus", { viewerAllowed: true }, async (payload) => + requireService(args.conflictService, "Conflict service not available.").getLaneStatus(parseConflictLaneArgs(payload, "conflicts.getLaneStatus"))); + register("conflicts.listOverlaps", { viewerAllowed: true }, async (payload) => + requireService(args.conflictService, "Conflict service not available.").listOverlaps(parseConflictLaneArgs(payload, "conflicts.listOverlaps"))); + register("conflicts.getBatchAssessment", { viewerAllowed: true }, async () => + requireService(args.conflictService, "Conflict service not available.").getBatchAssessment()); + + register("prs.list", { viewerAllowed: true }, async () => args.prService.listAll()); + register("prs.refresh", { viewerAllowed: true }, async (payload) => { + const prId = asTrimmedString(payload.prId); + await args.prService.refresh(prId ? { prId } : {}); + const prs = await args.prService.listAll(); + return { + refreshedCount: prId ? 1 : prs.length, + prs, + snapshots: args.prService.listSnapshots(), + }; + }); + register("prs.getDetail", { viewerAllowed: true }, async (payload) => args.prService.getDetail(requirePrId(payload, "prs.getDetail"))); + register("prs.getStatus", { viewerAllowed: true }, async (payload) => args.prService.getStatus(requirePrId(payload, "prs.getStatus"))); + register("prs.getChecks", { viewerAllowed: true }, async (payload) => args.prService.getChecks(requirePrId(payload, "prs.getChecks"))); + register("prs.getReviews", { viewerAllowed: true }, async (payload) => args.prService.getReviews(requirePrId(payload, "prs.getReviews"))); + register("prs.getComments", { viewerAllowed: true }, async (payload) => args.prService.getComments(requirePrId(payload, "prs.getComments"))); + register("prs.getFiles", { viewerAllowed: true }, async (payload) => args.prService.getFiles(requirePrId(payload, "prs.getFiles"))); + register("prs.createFromLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createFromLane(parseCreatePrArgs(payload))); + register("prs.land", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.land(parseLandPrArgs(payload))); + register("prs.close", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.closePr(parseClosePrArgs(payload)); + return { ok: true }; + }); + register("prs.reopen", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.reopenPr(parseReopenPrArgs(payload)); + return { ok: true }; + }); + register("prs.requestReviewers", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.requestReviewers(parseRequestReviewersArgs(payload)); + return { ok: true }; + }); + return { getSupportedActions(): SyncRemoteCommandAction[] { return [...registry.keys()]; }, + getDescriptors(): SyncRemoteCommandDescriptor[] { + return [...registry.values()].map((entry) => entry.descriptor); + }, + getPolicy(action: string): SyncRemoteCommandPolicy | null { - return registry.get(action as SyncRemoteCommandAction)?.policy ?? null; + return registry.get(action as SyncRemoteCommandAction)?.descriptor.policy ?? null; }, async execute(payload: SyncCommandPayload): Promise { @@ -276,7 +1050,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg const commandArgs = isRecord(payload.args) ? payload.args : {}; args.logger.debug?.("sync.remote_command.execute", { action: payload.action, - policy: handler.policy, + policy: handler.descriptor.policy, }); return await handler.handler(commandArgs); }, diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index 870b76a2..b829ae58 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -251,4 +251,86 @@ describe("syncService", () => { expect(transferred.currentBrain?.deviceId).toBe(localDevice.deviceId); expect(transferred.transferReadiness.ready).toBe(true); }); + + it("builds pairing QR payloads with LAN-first address candidates and tailscale fallback", async () => { + const projectRoot = makeProjectRoot("ade-sync-service-pairing-"); + const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + fileService: { dispose: () => {} } as any, + laneService: { list: async () => [], create: async () => ({}), archive: async () => {} } as any, + prService: { + listAll: async () => [], + getDetail: async () => null, + getStatus: async () => null, + getChecks: async () => [], + getReviews: async () => [], + getComments: async () => [], + getFiles: async () => [], + createFromLane: async () => ({}), + land: async () => ({}), + closePr: async () => {}, + requestReviewers: async () => {}, + } as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + missionService: { list: () => [] } as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + }); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + const initialStatus = await service.getStatus(); + const localDeviceId = initialStatus.localDevice.deviceId; + const now = "2026-03-17T00:00:00.000Z"; + db.run( + `update devices + set ip_addresses_json = ?, + last_host = ?, + last_port = ?, + tailscale_ip = ?, + updated_at = ?, + last_seen_at = ? + where device_id = ?`, + [ + JSON.stringify(["192.168.0.5", "192.168.0.8"]), + "192.168.0.20", + 8787, + "100.100.12.4", + now, + now, + localDeviceId, + ], + ); + + const status = await service.getStatus(); + expect(status.mode === "brain" || status.mode === "standalone").toBe(true); + expect(status.pairingSession).toBeTruthy(); + expect(status.pairingConnectInfo).toBeTruthy(); + const addressCandidates = status.pairingConnectInfo?.addressCandidates ?? []; + const savedCandidateIndex = addressCandidates.findIndex((entry) => entry.kind === "saved" && entry.host === "192.168.0.20"); + const tailscaleCandidateIndex = addressCandidates.findIndex((entry) => entry.kind === "tailscale" && entry.host === "100.100.12.4"); + expect(savedCandidateIndex).toBeGreaterThanOrEqual(0); + expect(tailscaleCandidateIndex).toBeGreaterThan(savedCandidateIndex); + expect(addressCandidates.slice(0, Math.max(savedCandidateIndex, 0)).every((entry) => entry.kind === "lan")).toBe(true); + + const encodedPayload = status.pairingConnectInfo?.qrPayloadText.split("payload=")[1] ?? ""; + const parsedPayload = JSON.parse(decodeURIComponent(encodedPayload)) as { + hostIdentity: { deviceId: string }; + pairingCode: string; + expiresAt: string; + }; + expect(parsedPayload.hostIdentity.deviceId).toBe(localDeviceId); + expect(parsedPayload.pairingCode).toBe(status.pairingSession?.code); + expect(parsedPayload.expiresAt).toBe(status.pairingSession?.expiresAt); + }); }); diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index b77f6af0..20159141 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -2,8 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import type { + SyncAddressCandidate, SyncDesktopConnectionDraft, SyncDeviceRuntimeState, + SyncPairingConnectInfo, + SyncPairingQrPayload, SyncRoleSnapshot, SyncTransferBlocker, SyncTransferReadiness, @@ -11,8 +14,17 @@ import type { import type { Logger } from "../logging/logger"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; +import type { createProjectConfigService } from "../config/projectConfigService"; import type { createFileService } from "../files/fileService"; +import type { createDiffService } from "../diffs/diffService"; +import type { createGitOperationsService } from "../git/gitOperationsService"; +import type { createConflictService } from "../conflicts/conflictService"; +import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; import type { createLaneService } from "../lanes/laneService"; +import type { createLaneTemplateService } from "../lanes/laneTemplateService"; +import type { createAutoRebaseService } from "../lanes/autoRebaseService"; +import type { createPortAllocationService } from "../lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; import type { createMissionService } from "../missions/missionService"; import type { createProcessService } from "../processes/processService"; import type { createPrService } from "../prs/prService"; @@ -31,9 +43,18 @@ type SyncServiceArgs = { projectRoot: string; fileService: ReturnType; laneService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; prService: ReturnType; sessionService: ReturnType; ptyService: ReturnType; + projectConfigService?: ReturnType; + portAllocationService?: ReturnType; + laneEnvironmentService?: ReturnType; + laneTemplateService?: ReturnType; + rebaseSuggestionService?: ReturnType | null; + autoRebaseService?: ReturnType | null; computerUseArtifactBrokerService: ReturnType; missionService: ReturnType; agentChatService: ReturnType; @@ -63,6 +84,64 @@ function sanitizeDraft(raw: unknown, token: string | null): SyncDesktopConnectio }; } +function normalizeHost(host: string | null | undefined): string | null { + if (!host) return null; + const normalized = host.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function buildAddressCandidates(localDevice: SyncRoleSnapshot["localDevice"]): SyncAddressCandidate[] { + const candidates: SyncAddressCandidate[] = []; + const seen = new Set(); + const append = (host: string | null | undefined, kind: SyncAddressCandidate["kind"]) => { + const normalized = normalizeHost(host); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + candidates.push({ host: normalized, kind }); + }; + for (const lanAddress of localDevice.ipAddresses) { + append(lanAddress, "lan"); + } + append(localDevice.lastHost, "saved"); + append(localDevice.tailscaleIp, "tailscale"); + return candidates; +} + +function buildPairingConnectInfo(argsIn: { + localDevice: SyncRoleSnapshot["localDevice"]; + pairingSession: SyncRoleSnapshot["pairingSession"]; +}): SyncPairingConnectInfo | null { + const pairingSession = argsIn.pairingSession; + if (!pairingSession) return null; + const port = argsIn.localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; + const addressCandidates = buildAddressCandidates(argsIn.localDevice); + const hostIdentity = { + deviceId: argsIn.localDevice.deviceId, + siteId: argsIn.localDevice.siteId, + name: argsIn.localDevice.name, + platform: argsIn.localDevice.platform, + deviceType: argsIn.localDevice.deviceType, + }; + const qrPayload: SyncPairingQrPayload = { + version: 1, + hostIdentity, + port, + pairingCode: pairingSession.code, + expiresAt: pairingSession.expiresAt, + addressCandidates, + }; + const qrPayloadText = `ade-sync://pair?payload=${encodeURIComponent(JSON.stringify(qrPayload))}`; + return { + hostIdentity, + port, + pairingCode: pairingSession.code, + expiresAt: pairingSession.expiresAt, + addressCandidates, + qrPayload, + qrPayloadText, + }; +} + export function createSyncService(args: SyncServiceArgs) { const layout = resolveAdeLayout(args.projectRoot); const draftPath = path.join(layout.secretsDir, DRAFT_FILE); @@ -169,9 +248,19 @@ export function createSyncService(args: SyncServiceArgs) { projectRoot: args.projectRoot, fileService: args.fileService, laneService: args.laneService, + gitService: args.gitService, + diffService: args.diffService, + conflictService: args.conflictService, prService: args.prService, sessionService: args.sessionService, ptyService: args.ptyService, + agentChatService: args.agentChatService, + projectConfigService: args.projectConfigService, + portAllocationService: args.portAllocationService, + laneEnvironmentService: args.laneEnvironmentService, + laneTemplateService: args.laneTemplateService, + rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, + autoRebaseService: args.autoRebaseService ?? undefined, computerUseArtifactBrokerService: args.computerUseArtifactBrokerService, bootstrapTokenPath: tokenPath, port: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, @@ -271,7 +360,7 @@ export function createSyncService(args: SyncServiceArgs) { ...device, isLocal, isBrain: device.deviceId === currentBrainId, - connectionState: isLocal ? "self" : peer ? "connected" : "disconnected", + connectionState: isLocal ? "self" : (peer ? "connected" : "disconnected"), connectedAt: peer?.connectedAt ?? null, lastAppliedAt: peer?.lastAppliedAt ?? null, remoteAddress: peer?.remoteAddress ?? null, @@ -354,20 +443,23 @@ export function createSyncService(args: SyncServiceArgs) { const cluster = deviceRegistryService.getClusterState(); const savedDraft = readSavedDraft(); const currentBrain = cluster ? deviceRegistryService.getDevice(cluster.brainDeviceId) : localDevice; - const role = cluster - ? (cluster.brainDeviceId !== localDevice.deviceId ? "viewer" : "brain") - : (savedDraft || syncPeerService.isConnected() ? "viewer" : "brain"); + const isLocalBrain = cluster + ? cluster.brainDeviceId === localDevice.deviceId + : !savedDraft && !syncPeerService.isConnected(); + const role = isLocalBrain ? "brain" : "viewer"; const client = syncPeerService.getStatus(); + const pairingSession = role === "brain" && hostService ? hostService.getPairingSession() : null; + const mode = role === "viewer" ? "viewer" + : (client.state === "connected" ? "brain" : "standalone"); return { - mode: role === "brain" - ? (client.state === "connected" ? "brain" : "standalone") - : "viewer", + mode, role, localDevice, currentBrain, clusterState: cluster, bootstrapToken: role === "brain" ? readToken() : null, - pairingSession: role === "brain" && hostService ? hostService.getPairingSession() : null, + pairingSession, + pairingConnectInfo: role === "brain" ? buildPairingConnectInfo({ localDevice, pairingSession }) : null, connectedPeers: hostService ? hostService.getPeerStates() : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []), diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 23be8635..0f0261cf 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -15,6 +15,31 @@ import type { ProcessRuntime, RecentProjectSummary, SyncRoleSnapshot } from "../ const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = ["starting", "running", "degraded"]; +function syncDotClass(snapshot: SyncRoleSnapshot): string { + if (snapshot.client.state === "error") return "ade-status-dot-error"; + if (snapshot.client.state === "connected" || snapshot.mode === "brain") return "ade-status-dot-active"; + return "ade-status-dot-warning"; +} + +function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { + if (!snapshot) return null; + if (snapshot.mode === "brain") { + const count = snapshot.connectedPeers.length; + return `Hosting locally · ${count} controller${count === 1 ? "" : "s"}`; + } + if (snapshot.mode === "standalone") return "Phone pairing ready"; + switch (snapshot.client.state) { + case "connected": + return `Connected to host · ${snapshot.currentBrain?.name ?? "host"}`; + case "connecting": + return "Connecting to host…"; + case "error": + return "Host link error"; + default: + return "No host link"; + } +} + export function TopBar() { const project = useAppStore((s) => s.project); const closeProject = useAppStore((s) => s.closeProject); @@ -226,15 +251,7 @@ export function TopBar() { setDropIdx(null); }, []); - const syncLabel = syncSnapshot - ? syncSnapshot.mode === "brain" - ? `Hosting locally · ${syncSnapshot.connectedPeers.length} controller${syncSnapshot.connectedPeers.length === 1 ? "" : "s"}` - : syncSnapshot.client.state === "connected" - ? `Connected to host · ${syncSnapshot.currentBrain?.name ?? "host"}` - : syncSnapshot.client.state === "connecting" - ? "Syncing…" - : "Offline" - : null; + const syncLabel = deriveSyncLabel(syncSnapshot); return (
{syncLabel} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx new file mode 100644 index 00000000..db048e26 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -0,0 +1,87 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import type { AgentChatEventEnvelope } from "../../../shared/types"; +import { AgentChatMessageList } from "./AgentChatMessageList"; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}{location.search}
; +} + +function renderMessageList(events: AgentChatEventEnvelope[]) { + render( + + + + , + ); +} + +afterEach(() => { + cleanup(); +}); + +describe("AgentChatMessageList operator navigation suggestions", () => { + it("renders Work suggestions from tool results and navigates by deeplink", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "tool_result", + tool: "spawnChat", + itemId: "tool-1", + status: "completed", + result: { + success: true, + navigationSuggestions: [ + { + surface: "work", + label: "Open in Work", + href: "/work?sessionId=chat-1", + sessionId: "chat-1", + }, + ], + }, + }, + }, + ]); + + fireEvent.click(screen.getByRole("button", { name: "Open in Work" })); + + expect(screen.getByTestId("location").textContent).toBe("/work?sessionId=chat-1"); + }); + + it("renders mission suggestions from tool results and navigates by deeplink", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "tool_result", + tool: "startMission", + itemId: "tool-2", + status: "completed", + result: { + success: true, + navigationSuggestions: [ + { + surface: "missions", + label: "Open mission", + href: "/missions?missionId=mission-1", + missionId: "mission-1", + }, + ], + }, + }, + }, + ]); + + fireEvent.click(screen.getByRole("button", { name: "Open mission" })); + + expect(screen.getByTestId("location").textContent).toBe("/missions?missionId=mission-1"); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 3cfe08de..abef9927 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { useNavigate } from "react-router-dom"; import { CaretDown, CaretRight, @@ -27,6 +28,7 @@ import type { ChatSurfaceChipTone, ChatSurfaceProfile, ChatSurfaceMode, + OperatorNavigationSuggestion, } from "../../../shared/types"; import { getModelById, resolveModelDescriptor } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; @@ -52,6 +54,38 @@ function readRecord(value: unknown): Record | null { : null; } +const NAVIGATION_SURFACES = new Set(["work", "missions", "lanes", "cto"]); + +function readOperatorNavigationSuggestion(value: unknown): OperatorNavigationSuggestion | null { + const record = readRecord(value); + if (!record) return null; + const surface = typeof record.surface === "string" ? record.surface : ""; + const href = typeof record.href === "string" ? record.href : ""; + const label = typeof record.label === "string" ? record.label : ""; + if (!NAVIGATION_SURFACES.has(surface) || !href.trim() || !label.trim()) return null; + const result: OperatorNavigationSuggestion = { surface: surface as OperatorNavigationSuggestion["surface"], href, label }; + if (typeof record.laneId === "string") result.laneId = record.laneId; + if (typeof record.sessionId === "string") result.sessionId = record.sessionId; + if (typeof record.missionId === "string") result.missionId = record.missionId; + return result; +} + +function readNavigationSuggestions(value: unknown): OperatorNavigationSuggestion[] { + const record = readRecord(value); + if (!record) return []; + const suggestions: OperatorNavigationSuggestion[] = []; + const navigationSuggestions = Array.isArray(record.navigationSuggestions) + ? record.navigationSuggestions + : []; + for (const candidate of navigationSuggestions) { + const parsed = readOperatorNavigationSuggestion(candidate); + if (parsed) suggestions.push(parsed); + } + if (suggestions.length > 0) return suggestions; + const fallback = readOperatorNavigationSuggestion(record.navigation); + return fallback ? [fallback] : []; +} + function summarizeStructuredValue(value: unknown, maxChars = 160): string { const text = formatStructuredValue(value).replace(/\s+/g, " ").trim(); return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text; @@ -70,6 +104,27 @@ function formatTokenCount(value: number | null | undefined): string | null { return String(Math.round(value)); } +function renderSubagentUsage(usage: { + totalTokens?: number; + toolUses?: number; + durationMs?: number; +} | undefined): React.ReactNode { + if (!usage) return null; + return ( +
+ {usage.totalTokens != null ? ( + {formatTokenCount(usage.totalTokens)} tokens + ) : null} + {usage.toolUses != null ? ( + {usage.toolUses} tool use{usage.toolUses === 1 ? "" : "s"} + ) : null} + {usage.durationMs != null ? ( + {(usage.durationMs / 1000).toFixed(1)}s + ) : null} +
+ ); +} + const GLASS_CARD_CLASS = "overflow-hidden rounded-[14px] border border-white/[0.08] bg-[#121216]"; @@ -336,6 +391,31 @@ function appendCollapsedEvent(out: RenderEnvelope[], envelope: AgentChatEventEnv return; } + if (event.type === "subagent_progress") { + const matchIndex = [...out] + .reverse() + .findIndex((candidate) => + candidate.event.type === "subagent_progress" + && candidate.event.taskId === event.taskId + && (candidate.event.turnId ?? null) === (event.turnId ?? null), + ); + if (matchIndex >= 0) { + const actualIndex = out.length - 1 - matchIndex; + out[actualIndex] = { + ...out[actualIndex]!, + timestamp: envelope.timestamp, + event, + }; + return; + } + out.push({ + key: `${envelope.sessionId}:${sequence}:${envelope.timestamp}`, + timestamp: envelope.timestamp, + event, + }); + return; + } + // structured_question, tool_use_summary, context_compact, system_notice: push normally if ( event.type === "structured_question" @@ -667,11 +747,13 @@ function ActivityIndicator({ activity, detail }: { activity: string; detail?: st const TOOL_RESULT_TRUNCATE_LIMIT = 500; function ToolResultCard({ event }: { event: Extract }) { + const navigate = useNavigate(); const [expanded, setExpanded] = useState(false); const meta = getToolMeta(event.tool); const ToolIcon = meta.icon; const toolDisplay = describeToolIdentifier(event.tool); const sourceChip = toolSourceChip(event.tool); + const navigationSuggestions = readNavigationSuggestions(event.result); const resultStr = formatStructuredValue(event.result); const isTruncated = resultStr.length > TOOL_RESULT_TRUNCATE_LIMIT; const displayStr = !expanded && isTruncated ? `${resultStr.slice(0, TOOL_RESULT_TRUNCATE_LIMIT)}...` : resultStr; @@ -679,7 +761,6 @@ function ToolResultCard({ event }: { event: Extract @@ -706,8 +787,23 @@ function ToolResultCard({ event }: { event: Extract } + defaultOpen={navigationSuggestions.length > 0} className="border-transparent" > + {navigationSuggestions.length > 0 ? ( +
+ {navigationSuggestions.map((suggestion) => ( + + ))} +
+ ) : null}
         {displayStr}
       
@@ -1045,6 +1141,38 @@ function renderEvent( ); } + /* ── Subagent Progress ── */ + if (event.type === "subagent_progress") { + const summaryText = summarizeInlineText(event.summary, 140); + return ( + + + + Subagent running + + {summaryText ? {summaryText} : null} + + } + className="border-violet-500/12" + > +
+
+ Task {event.taskId} + {event.description?.trim() ? {event.description.trim()} : null} + {event.lastToolName?.trim() ? Tool {event.lastToolName.trim()} : null} +
+
+ {event.summary.trim() || "Waiting for the next progress update."} +
+ {renderSubagentUsage(event.usage)} +
+
+ ); + } + /* ── Subagent Result ── */ if (event.type === "subagent_result") { const isSuccess = event.status === "completed"; @@ -1070,19 +1198,7 @@ function renderEvent( >
{event.summary}
- {event.usage ? ( -
- {event.usage.totalTokens != null ? ( - {formatTokenCount(event.usage.totalTokens)} tokens - ) : null} - {event.usage.toolUses != null ? ( - {event.usage.toolUses} tool use{event.usage.toolUses === 1 ? "" : "s"} - ) : null} - {event.usage.durationMs != null ? ( - {(event.usage.durationMs / 1000).toFixed(1)}s - ) : null} -
- ) : null} + {renderSubagentUsage(event.usage)}
); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.test.tsx new file mode 100644 index 00000000..f925fdf5 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.test.tsx @@ -0,0 +1,56 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ChatSubagentStrip } from "./ChatSubagentStrip"; +import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; + +function renderStrip(snapshot: ChatSubagentSnapshot) { + const onInterruptTurn = vi.fn(); + render( + , + ); + return { onInterruptTurn }; +} + +describe("ChatSubagentStrip", () => { + it("shows running progress summaries when they are available", () => { + renderStrip({ + taskId: "task-1", + description: "Inspect desktop IPC path", + status: "running", + startedAt: "2026-03-10T12:00:00.000Z", + updatedAt: "2026-03-10T12:00:02.000Z", + summary: "Traced the send handler and found the blocking await.", + usage: { + totalTokens: 800, + toolUses: 2, + }, + lastToolName: "functions.exec_command", + }); + + fireEvent.click(screen.getByRole("button", { name: /Inspect desktop IPC path/ })); + + expect(screen.getByText("Traced the send handler and found the blocking await.")).not.toBeNull(); + expect(screen.getByText("Tool functions.exec_command")).not.toBeNull(); + }); + + it("falls back to the last tool when live progress text is unavailable", () => { + renderStrip({ + taskId: "task-2", + description: "Check Claude warmup lifecycle", + status: "running", + startedAt: "2026-03-10T12:00:00.000Z", + updatedAt: "2026-03-10T12:00:03.000Z", + summary: null, + lastToolName: "functions.exec_command", + }); + + fireEvent.click(screen.getByRole("button", { name: /Check Claude warmup lifecycle/ })); + + expect(screen.getByText("Running. Last tool: functions.exec_command.")).not.toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.tsx index da76f135..ffdfac59 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.tsx @@ -67,7 +67,10 @@ function statusMeta(status: ChatSubagentSnapshot["status"]): { function previewText(snapshot: ChatSubagentSnapshot): string { if (snapshot.summary?.trim()) return snapshot.summary.trim(); if (snapshot.status === "running") { - return "Live subagent transcript is not exposed by the current runtime yet. The parent turn can still be interrupted from the composer."; + if (snapshot.lastToolName?.trim()) { + return `Running. Last tool: ${snapshot.lastToolName.trim()}.`; + } + return "Running. Waiting for the next progress update."; } return "No summary was returned for this subagent."; } @@ -117,6 +120,9 @@ function PreviewCard({
Task {snapshot.taskId} + {snapshot.status === "running" && snapshot.lastToolName?.trim() ? ( + Tool {snapshot.lastToolName.trim()} + ) : null} - ))} -
- - - {identity.personality === "custom" ? ( -