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/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8372135f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/apps/desktop" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + - package-ecosystem: "npm" + directory: "/apps/mcp-server" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8eebc687..e2119ad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,39 +10,177 @@ permissions: contents: read jobs: - check: + # ── Stage 1: Install & cache ────────────────────────────────────────── + install: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - cache: npm - cache-dependency-path: | - apps/desktop/package-lock.json - apps/mcp-server/package-lock.json - apps/web/package-lock.json - - name: Install MCP server dependencies - run: cd apps/mcp-server && npm ci + - name: Restore node_modules cache + id: cache + uses: actions/cache@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} - - name: Install desktop dependencies - run: cd apps/desktop && npm ci + - name: Install all dependencies (parallel) + if: steps.cache.outputs.cache-hit != 'true' + run: | + cd apps/desktop && npm ci & + cd apps/mcp-server && npm ci & + cd apps/web && npm ci & + wait - - name: Install web dependencies - run: cd apps/web && npm ci + # ── Stage 2: Parallel checks ────────────────────────────────────────── + typecheck-desktop: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/desktop && npm run typecheck - - name: Typecheck - run: cd apps/desktop && npm run typecheck + typecheck-mcp: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/mcp-server && npm run typecheck + + lint-desktop: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/desktop && npm run lint + + test-desktop: + needs: install + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/desktop && npx vitest run --shard=${{ matrix.shard }}/3 - - name: Test desktop - run: cd apps/desktop && npm test + test-mcp: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/mcp-server && npm test - - name: Test MCP server - run: cd apps/mcp-server && npm test + build: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/desktop && npm run build + - run: cd apps/mcp-server && npm run build + - run: cd apps/web && npm run build - - name: Build web - run: cd apps/web && npm run build + validate-docs: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/cache/restore@v4 + with: + path: | + apps/desktop/node_modules + apps/mcp-server/node_modules + apps/web/node_modules + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }} + - run: node scripts/validate-docs.mjs - - name: Validate docs - run: node scripts/validate-docs.mjs + # ── Gate: all jobs must pass ────────────────────────────────────────── + ci-pass: + if: always() + needs: + - typecheck-desktop + - typecheck-mcp + - lint-desktop + - test-desktop + - test-mcp + - build + - validate-docs + runs-on: ubuntu-latest + steps: + - name: Check all jobs passed + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]] || + [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "::error::One or more required jobs failed or were cancelled" + exit 1 + fi + echo "All CI jobs passed" 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/.gitignore b/.gitignore index f85e11e0..4f20e767 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ xcuserdata/ .pnpm-store/ /apps/desktop/.ade /.playwright-mcp +/.codex-derived-data 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..240851a2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,9 +11,18 @@ "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:unit": "vitest run --project unit", + "test:integration": "vitest run --project integration", + "test:component": "vitest run --project component", + "test:coverage": "vitest run --coverage", "test:orchestrator-smoke": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts --reporter=verbose", "test:orchestrator-complex-mock": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts -t \"complex mock prompt\" --reporter=verbose", "mcp:dev": "npm --prefix ../mcp-server run dev -- --project-root ../..", @@ -44,6 +53,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 +66,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 +81,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 +90,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 +142,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..1ca7b6af 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -26,7 +26,6 @@ import { createTestService } from "./services/tests/testService"; import { createOperationService } from "./services/history/operationService"; import { createGitOperationsService } from "./services/git/gitOperationsService"; import { runGit } from "./services/git/git"; -import { createPackService } from "./services/packs/packService"; import { createJobEngine } from "./services/jobs/jobEngine"; import { createAiIntegrationService } from "./services/ai/aiIntegrationService"; import { createAgentChatService } from "./services/chat/agentChatService"; @@ -714,20 +713,6 @@ app.whenReady().then(async () => { projectRoot, }); - const packService = createPackService({ - db, - logger, - projectRoot, - projectId, - packsDir: adePaths.packsDir, - laneService, - sessionService, - projectConfigService, - operationService, - aiIntegrationService, - onEvent: () => {} - }); - const onboardingService = createOnboardingService({ db, logger, @@ -881,6 +866,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 }); @@ -1172,7 +1167,7 @@ app.whenReady().then(async () => { transcriptsDir: adePaths.transcriptsDir, projectId, memoryService, - packService, + fileService, workerAgentService, workerHeartbeatService, linearIssueTracker, @@ -1183,6 +1178,7 @@ app.whenReady().then(async () => { linearClient, linearCredentials: linearCredentialService, prService, + processService, episodicSummaryService, laneService, sessionService, @@ -1249,16 +1245,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, @@ -1501,7 +1487,6 @@ app.whenReady().then(async () => { db, projectId, projectRoot, - packService, conflictService, ptyService, agentChatService, @@ -1572,9 +1557,18 @@ app.whenReady().then(async () => { projectRoot, fileService, laneService, + gitService, + diffService, + conflictService, prService, sessionService, ptyService, + projectConfigService, + portAllocationService, + laneEnvironmentService, + laneTemplateService, + rebaseSuggestionService, + autoRebaseService, computerUseArtifactBrokerService, missionService, agentChatService, @@ -2019,17 +2013,25 @@ app.whenReady().then(async () => { sessionService, operationService, projectConfigService, - packService, conflictService, gitService, diffService, missionService, ptyService, testService, + agentChatService, prService, + fileService, memoryService, ctoStateService, workerAgentService, + flowPolicyService, + linearDispatcherService, + linearIssueTracker, + linearSyncService, + linearIngressService, + linearRoutingService, + processService, externalMcpService, computerUseArtifactBrokerService, orchestratorService, @@ -2130,7 +2132,6 @@ app.whenReady().then(async () => { missionBudgetService, aiOrchestratorService, agentChatService, - packService, contextDocService, projectConfigService, processService, @@ -2223,7 +2224,6 @@ app.whenReady().then(async () => { orchestratorService: null, missionBudgetService: null, aiOrchestratorService: null, - packService: null, contextDocService: null, projectConfigService: null, processService: null, @@ -2480,17 +2480,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/agentExecutor.ts b/apps/desktop/src/main/services/ai/agentExecutor.ts index b82c652a..d6839102 100644 --- a/apps/desktop/src/main/services/ai/agentExecutor.ts +++ b/apps/desktop/src/main/services/ai/agentExecutor.ts @@ -26,7 +26,6 @@ export type CodexProviderOverrides = { export type ExecutorOpts = { cwd: string; - contextPack?: unknown; systemPrompt?: string; jsonSchema?: unknown; model?: string; 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..31e6a328 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,8 +58,9 @@ 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" }), - fetchMissionContext: vi.fn().mockResolvedValue({ content: "ctx", truncated: false }), ...overrides, }; } @@ -103,7 +107,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 +171,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 +366,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..cc6d46ac 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -11,15 +11,19 @@ import type { AgentUpsertInput, 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 +37,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 +65,16 @@ 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; - 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 +130,99 @@ 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 allWorkspaces = deps.fileService.listWorkspaces({ includeArchived: true }); + const explicitWorkspaceId = args.workspaceId?.trim() || ""; + if (explicitWorkspaceId) { + const workspace = allWorkspaces.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; + // Prefer active workspaces; fall back to archived only if no active match. + const activeWorkspaces = deps.fileService.listWorkspaces({ includeArchived: false }); + const laneWorkspace = + activeWorkspaces.find((entry) => entry.laneId === laneId) ?? + allWorkspaces.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 +256,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 +479,11 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { try { @@ -395,7 +513,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { try { @@ -407,10 +525,50 @@ 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().trim().min(1), + }), + 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({ - sessionId: z.string(), + sessionId: z.string().trim().min(1), }), execute: async ({ sessionId }) => { const session = await deps.getChatStatus(sessionId); @@ -489,7 +647,16 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - if (!deps.fetchMissionContext) return { success: false, error: "Mission context export is not available." }; - try { - const result = await deps.fetchMissionContext(missionId); - return { success: true, missionId, ...result }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, - }); - tools.listWorkers = tool({ description: "List worker agents in the CTO org.", inputSchema: z.object({ @@ -819,6 +995,268 @@ 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().trim().min(1), + 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().trim().min(1), + body: z.string().trim().min(1), + }), + 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().trim().min(1), + title: z.string().trim().min(1), + }), + 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().trim().min(1), + body: z.string().min(1), + }), + 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().trim().min(1).optional(), + laneId: z.string().trim().min(1).optional(), + path: z.string().trim().min(1), + }), + 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().trim().min(1).optional(), + laneId: z.string().trim().min(1).optional(), + query: z.string().trim().min(1), + 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.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().trim().min(1).optional(), + processId: z.string().trim().min(1), + }), + 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().trim().min(1).optional(), + processId: z.string().trim().min(1), + }), + 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().trim().min(1).optional(), + processId: z.string().trim().min(1), + 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/ai/tools/index.ts b/apps/desktop/src/main/services/ai/tools/index.ts index 5313d253..37514ec6 100644 --- a/apps/desktop/src/main/services/ai/tools/index.ts +++ b/apps/desktop/src/main/services/ai/tools/index.ts @@ -33,4 +33,3 @@ export function createCodingToolSet( export { createUniversalToolSet } from "./universalTools"; export type { PermissionMode, UniversalToolSetOptions } from "./universalTools"; export { buildCodingAgentSystemPrompt, composeSystemPrompt } from "./systemPrompt"; -export { loadMcpTools } from "./mcpBridge"; diff --git a/apps/desktop/src/main/services/ai/tools/mcpBridge.ts b/apps/desktop/src/main/services/ai/tools/mcpBridge.ts deleted file mode 100644 index b725baed..00000000 --- a/apps/desktop/src/main/services/ai/tools/mcpBridge.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * MCP client integration stub. - * When @ai-sdk/mcp is installed, this module will bridge MCP servers - * into the Vercel AI SDK tool format. - */ -export async function loadMcpTools( - _mcpConfig?: Record -): Promise> { - // Stub: MCP tool integration will be added when @ai-sdk/mcp is installed. - // For now, return an empty tool set. - return {}; -} diff --git a/apps/desktop/src/main/services/ai/tools/teamMessageTool.ts b/apps/desktop/src/main/services/ai/tools/teamMessageTool.ts deleted file mode 100644 index 15113d0f..00000000 --- a/apps/desktop/src/main/services/ai/tools/teamMessageTool.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; -import { tool } from "ai"; - -export function createTeamMessageTool(opts: { - sendCallback: (args: { target: string; message: string; fromAttemptId: string }) => Promise; - currentAttemptId: string; -}) { - return tool({ - description: - "Send a message to another agent or the orchestrator. Use @step-key to target a specific agent, @orchestrator for the orchestrator, or @all for broadcast.", - inputSchema: z.object({ - target: z.string().describe("Target: step-key, 'orchestrator', or 'all'"), - message: z.string().describe("The message content"), - }), - execute: async ({ target, message }) => { - await opts.sendCallback({ - target, - message, - fromAttemptId: opts.currentAttemptId, - }); - return { delivered: true, target }; - }, - }); -} diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index 17a4a3af..fab25f52 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -222,12 +222,6 @@ function buildPlannerSchema(): Record { minItems: 1, items: { oneOf: [ - { - type: "object", - additionalProperties: false, - properties: { type: { const: "update-packs" }, ...baseActionProps }, - required: ["type"] - }, { type: "object", additionalProperties: false, @@ -291,7 +285,6 @@ function buildPlannerPrompt(args: { "- linear.issue_status_changed", "", "Available actions:", - "- update-packs", "- predict-conflicts", "- run-tests (requires suite string; use a suite id or name below)", "- run-command (requires command string; keep it a single shell command)", @@ -608,7 +601,6 @@ function normalizeDraft(args: { } satisfies Partial; if ( - type !== "update-packs" && type !== "predict-conflicts" && type !== "run-tests" && type !== "run-command" @@ -1186,9 +1178,6 @@ export function createAutomationPlannerService({ warnings }; } - if (action.type === "update-packs") { - return { index, type: action.type, summary: "Refresh packs (lane packs + project pack)", warnings }; - } if (action.type === "predict-conflicts") { return { index, type: action.type, summary: "Run conflict prediction", warnings }; } diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 2301c252..27c52307 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -1398,9 +1398,6 @@ export function createAutomationService({ if (raw === "provider-enabled" && (projectConfigService.get().effective.providerMode ?? "guest") === "guest") { return { status: "skipped", output: "Provider mode disabled." }; } - if (action.type === "update-packs") { - return { status: "succeeded", output: "update-packs is deprecated; unified memory lifecycle runs automatically." }; - } if (action.type === "predict-conflicts") { if (!conflictService) throw new Error("Conflict service unavailable"); await conflictService.runPrediction(trigger.laneId ? { laneId: trigger.laneId } : {}); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 169919d0..fd05e1d5 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -27,7 +27,8 @@ 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 { createPackService } from "../packs/packService"; +import type { createFileService } from "../files/fileService"; +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 +197,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; @@ -205,6 +208,8 @@ type ClaudeRuntime = { pendingSteers: string[]; approvals: Map; interrupted: boolean; + /** Set when a reasoning effort change is requested mid-turn; flushed when idle. */ + pendingSessionReset?: boolean; }; type PendingUnifiedApproval = { @@ -300,6 +305,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"; @@ -350,6 +364,7 @@ const CLAUDE_REASONING_EFFORTS: Array<{ effort: string; description: string }> = { effort: "low", description: "Quick responses with minimal reasoning." }, { effort: "medium", description: "Balanced reasoning depth and speed." }, { effort: "high", description: "Deep reasoning for complex tasks." }, + { effort: "max", description: "Maximum reasoning depth. Best for Opus on hard problems." }, ]; const CLAUDE_EFFORT_TO_TOKENS: Record = { @@ -445,7 +460,7 @@ const KNOWN_CODEX_EFFORTS = new Set(CODEX_REASONING_EFFORTS.map((e) => e.effort) const EFFORT_ALIASES: Record> = { codex: { minimal: "low", max: "xhigh", none: "low" }, - claude: { max: "high" }, + claude: {}, }; function validateReasoningEffort(provider: "codex" | "claude", effort: string | null | undefined): string | null { @@ -541,6 +556,10 @@ function buildPlanningApprovalViolation(toolName: string): string { return `PLANNER CONTRACT VIOLATION: '${toolName}' requested a provider-native approval flow during a planning step. Planning workers must stay inspect-only and return the plan via report_result instead.`; } +function isBackgroundTask(item: Record): boolean { + return !!(item.run_in_background || item.background); +} + function normalizePreview(text: string, maxChars = 220): string | null { const lines = text .split(/\r?\n/) @@ -987,7 +1006,7 @@ export function createAgentChatService(args: { transcriptsDir: string; projectId?: string; memoryService?: ReturnType | null; - packService?: ReturnType | null; + fileService?: ReturnType | null; episodicSummaryService?: EpisodicSummaryService | null; ctoStateService?: ReturnType | null; workerAgentService?: ReturnType | null; @@ -1000,6 +1019,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; @@ -1014,7 +1034,7 @@ export function createAgentChatService(args: { transcriptsDir, projectId, memoryService, - packService, + fileService, episodicSummaryService, ctoStateService, workerAgentService, @@ -1027,6 +1047,7 @@ export function createAgentChatService(args: { linearClient: linearClientRef, linearCredentials: linearCredentialsRef, prService, + processService, computerUseArtifactBrokerService, laneService, sessionService, @@ -1064,7 +1085,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 +1097,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 +1125,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 +1984,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 +2468,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 +2489,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 +2529,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 +2542,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 +2663,30 @@ 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 ?? ""); + if (!taskId) continue; + const existing = runtime.activeSubagents.get(taskId); + const description = String(taskMsg.description ?? existing?.description ?? ""); + 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; @@ -2615,6 +2696,7 @@ export function createAgentChatService(args: { type: "subagent_started", taskId, description: String(taskMsg.description ?? ""), + background: isBackgroundTask(taskMsg as Record), turnId, }); continue; @@ -2835,8 +2917,19 @@ export function createAgentChatService(args: { continue; } - // prompt_suggestion — follow-up suggestions (consume silently for now) + // prompt_suggestion — follow-up suggestions forwarded to the UI if ((msg as any).type === "prompt_suggestion") { + const suggestionMsg = msg as Record; + const suggestionText = + [suggestionMsg.suggestion, suggestionMsg.prompt, suggestionMsg.text] + .find((v): v is string => typeof v === "string" && v.trim().length > 0)?.trim() ?? null; + if (suggestionText) { + emitChatEvent(managed, { + type: "prompt_suggestion", + suggestion: suggestionText, + turnId, + }); + } continue; } } @@ -2849,6 +2942,15 @@ export function createAgentChatService(args: { managed.session.status = "idle"; reportProviderRuntimeReady("claude"); + // Flush deferred session reset from mid-turn reasoning effort change + if (runtime.pendingSessionReset) { + runtime.pendingSessionReset = false; + cancelClaudeWarmup(managed, runtime, "session_reset"); + try { runtime.v2Session?.close(); } catch { /* ignore */ } + runtime.v2Session = null; + runtime.v2WarmupDone = null; + } + const finalStatus = runtime.interrupted ? "interrupted" : "completed"; emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId }); emitChatEvent(managed, { @@ -3031,6 +3133,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 +3283,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 +3294,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,13 +3305,6 @@ export function createAgentChatService(args: { reuseExisting, permissionMode: "full-auto", }), - fetchMissionContext: async (missionId) => { - const result = await fetchContextPack({ scope: "mission", missionId, level: "standard" }); - return { - content: result.content, - truncated: result.truncated, - }; - }, })); } } @@ -3237,6 +3350,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; @@ -3670,6 +3784,7 @@ export function createAgentChatService(args: { type: "subagent_started", taskId: itemId, description: String(item.description ?? item.title ?? "Delegated task"), + background: isBackgroundTask(item as Record), turnId, }); } @@ -3685,6 +3800,144 @@ export function createAgentChatService(args: { return; } + // collabToolCall items → subagent events (Codex parallel agents) + if (itemType === "collabToolCall") { + const tool = String(item.tool ?? ""); + const prompt = typeof item.prompt === "string" ? item.prompt : ""; + const agentsStates = Array.isArray(item.agentsStates) ? item.agentsStates : []; + const newThreadId = typeof item.newThreadId === "string" ? item.newThreadId : null; + + if (tool === "spawn_agent" && eventKind === "started") { + emitChatEvent(managed, { + type: "activity", + activity: "spawning_agent", + detail: prompt.slice(0, 80) || "Spawning parallel agent", + turnId, + }); + emitChatEvent(managed, { + type: "subagent_started", + taskId: newThreadId ?? itemId, + description: prompt.slice(0, 120) || "Parallel agent", + background: isBackgroundTask(item as Record), + turnId, + }); + } + + if ((tool === "send_input" || tool === "resume_agent") && eventKind === "completed") { + const receiverIds = Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : []; + const targetId = typeof receiverIds[0] === "string" ? receiverIds[0] : itemId; + emitChatEvent(managed, { + type: "subagent_progress", + taskId: targetId, + summary: prompt || "Agent received input", + turnId, + }); + } + + if (tool === "wait" && eventKind === "completed") { + for (const agentState of agentsStates) { + if (!agentState || typeof agentState !== "object") continue; + const state = agentState as Record; + const agentThreadId = typeof state.threadId === "string" ? state.threadId : itemId; + const summary = typeof state.summary === "string" ? state.summary + : typeof state.result === "string" ? state.result + : ""; + const rawStatus = String(state.status ?? "completed"); + const subagentStatus: "completed" | "failed" | "stopped" = + rawStatus === "failed" ? "failed" + : rawStatus === "stopped" ? "stopped" + : "completed"; + emitChatEvent(managed, { + type: "subagent_result", + taskId: agentThreadId, + status: subagentStatus, + summary, + turnId, + }); + } + } + + if (tool === "close_agent" && eventKind === "completed") { + const receiverIds = Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : []; + const targetId = typeof receiverIds[0] === "string" ? receiverIds[0] : itemId; + emitChatEvent(managed, { + type: "subagent_result", + taskId: targetId, + status: "stopped", + summary: "Agent closed", + turnId, + }); + } + + return; + } + + // dynamicToolCall items → tool_call/tool_result events + if (itemType === "dynamicToolCall") { + const toolName = String(item.tool ?? "dynamic_tool"); + if (eventKind === "started") { + emitChatEvent(managed, { + type: "activity", + activity: "tool_calling", + detail: toolName, + turnId, + }); + emitChatEvent(managed, { + type: "tool_call", + tool: toolName, + args: item.arguments, + itemId, + turnId, + }); + } + if (eventKind === "completed") { + const success = item.success !== false; + const contentItems = Array.isArray(item.contentItems) ? item.contentItems : []; + const resultText = contentItems + .map((ci: unknown) => { + if (typeof ci === "string") return ci; + if (ci && typeof ci === "object" && typeof (ci as Record).text === "string") { + return (ci as Record).text as string; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + emitChatEvent(managed, { + type: "tool_result", + tool: toolName, + result: resultText || (success ? "Completed" : "Failed"), + itemId, + turnId, + status: success ? "completed" : "failed", + }); + } + return; + } + + // webSearch items → web_search events + if (itemType === "webSearch") { + emitChatEvent(managed, { + type: "activity", + activity: "web_searching", + detail: String(item.query ?? "Searching the web"), + turnId, + }); + let status: "running" | "completed" | "failed" = "running"; + if (eventKind === "completed") { + status = String(item.status ?? "completed") === "failed" ? "failed" : "completed"; + } + emitChatEvent(managed, { + type: "web_search", + query: String(item.query ?? ""), + action: typeof item.action === "string" ? item.action : undefined, + itemId, + turnId, + status, + }); + return; + } + // Planning items → todo_update if (itemType === "planningItem" || itemType === "planning") { const steps = Array.isArray(item.steps) ? item.steps : Array.isArray(item.plan) ? item.plan : []; @@ -3961,6 +4214,54 @@ export function createAgentChatService(args: { return; } + if (method === "item/plan/delta") { + const delta = String((params.delta as string | undefined) ?? ""); + if (!delta.length) return; + emitChatEvent(managed, { + type: "plan_text", + text: delta, + turnId: typeof params.turnId === "string" ? params.turnId : undefined, + itemId: typeof params.itemId === "string" ? params.itemId : undefined, + }); + return; + } + + if (method === "item/reasoning/summaryPartAdded") { + // Summary part boundary — no additional handling needed since we already + // merge reasoning deltas by turnId/itemId/summaryIndex. + return; + } + + if (method === "item/autoApprovalReview/started") { + const targetItemId = String((params.targetItemId as string | undefined) ?? ""); + if (targetItemId) { + emitChatEvent(managed, { + type: "auto_approval_review", + targetItemId, + reviewStatus: "started", + turnId: typeof params.turnId === "string" ? params.turnId : undefined, + }); + } + return; + } + + if (method === "item/autoApprovalReview/completed") { + const targetItemId = String((params.targetItemId as string | undefined) ?? ""); + const action = typeof params.action === "string" ? params.action : undefined; + const review = typeof params.review === "string" ? params.review : undefined; + if (targetItemId) { + emitChatEvent(managed, { + type: "auto_approval_review", + targetItemId, + reviewStatus: "completed", + action, + review, + turnId: typeof params.turnId === "string" ? params.turnId : undefined, + }); + } + return; + } + // Log unhandled notification methods for debugging if (method) { logger.warn("agent_chat.codex_unhandled_notification", { @@ -4217,6 +4518,8 @@ export function createAgentChatService(args: { cwd: managed.laneWorktreePath, permissionMode: claudePermissionMode as any, includePartialMessages: true, + agentProgressSummaries: true, + promptSuggestions: true, maxBudgetUsd: chatConfig.sessionBudgetUsd ?? undefined, model: resolveClaudeCliModel(managed.session.model), }; @@ -4241,6 +4544,14 @@ export function createAgentChatService(args: { managed.session.computerUse, ) as any; if (canUseTool) opts.canUseTool = canUseTool as any; + + // Enable MCP tool search for sessions with many MCP tools. + // When enabled, the SDK defers tool definitions and loads them on-demand + // via the ToolSearch tool, keeping the context window lean. + opts.env = { + ...opts.env as Record | undefined, + ENABLE_TOOL_SEARCH: "auto", + }; } const claudeDescriptor = resolveSessionModelDescriptor(managed.session); const claudeSupportsReasoning = claudeDescriptor?.capabilities.reasoning ?? true; @@ -4264,11 +4575,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 +4625,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 +4659,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 +4669,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 +4725,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 +4756,7 @@ export function createAgentChatService(args: { v2Session: null, v2StreamGen: null, v2WarmupDone: null, + v2WarmupCancel: null, v2WarmupCancelled: false, activeSubagents: new Map(), slashCommands: [], @@ -4721,16 +5089,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 +5143,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 +5303,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 +5410,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 => { @@ -5452,6 +5925,18 @@ export function createAgentChatService(args: { ensureClaudeSessionRuntime(managed); prewarmClaudeV2Session(managed); } + + // If V2 session is alive and model changed, notify SDK + if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && modelId) { + const newCliModel = resolveClaudeCliModel(managed.session.model); + if (newCliModel && typeof (managed.runtime.v2Session as any).setModel === "function") { + try { + (managed.runtime.v2Session as any).setModel(newCliModel); + } catch (err) { + logger.warn("agent_chat.v2_set_model_failed", { sessionId: managed.session.id, error: String(err) }); + } + } + } } else if (reasoningEffort !== undefined) { const prev = managed.session.reasoningEffort ?? null; managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); @@ -5459,10 +5944,17 @@ 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(); - managed.runtime.v2Session = null; - managed.runtime.v2WarmupDone = null; + if (prev !== next && managed.runtime?.kind === "claude" && (managed.runtime.v2Session || managed.runtime.v2WarmupDone)) { + if (managed.runtime.busy) { + // Defer session reset until the current turn completes — tearing down + // a live session mid-turn would force the stream down the failure path. + managed.runtime.pendingSessionReset = true; + } else { + cancelClaudeWarmup(managed, managed.runtime, "session_reset"); + try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } + managed.runtime.v2Session = null; + managed.runtime.v2WarmupDone = null; + } } } @@ -5514,7 +6006,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. @@ -5527,78 +6019,6 @@ export function createAgentChatService(args: { prewarmClaudeV2Session(managed); }; - const listContextPacks = async (args: { laneId?: string } = {}): Promise => { - const packs: import("../../../shared/types").ContextPackOption[] = [ - { scope: "project", label: "Project", description: "Live project context export", available: Boolean(packService) }, - ]; - - if (args.laneId) { - packs.push( - { scope: "lane", label: "Lane", description: "Live lane context export", available: Boolean(packService), laneId: args.laneId }, - { scope: "conflict", label: "Conflicts", description: "Live conflict context export", available: Boolean(packService), laneId: args.laneId }, - { scope: "plan", label: "Plan", description: "Live plan context export", available: Boolean(packService), laneId: args.laneId } - ); - } - - packs.push( - { - scope: "mission", - label: "Mission", - description: "Mission-scoped export requires an explicit mission selection and is not wired into this picker yet", - available: false - } - ); - - return packs; - }; - - const fetchContextPack = async (args: import("../../../shared/types").ContextPackFetchArgs): Promise => { - const MAX_CHARS = 50_000; - let content = ""; - let truncated = false; - const level = args.level === "brief" ? "lite" : args.level === "detailed" ? "deep" : "standard"; - - try { - if (!packService) { - throw new Error("Live context export service is unavailable."); - } - - const exportResult = await (async () => { - if (args.scope === "project") return await packService.getProjectExport({ level }); - if (args.scope === "lane") { - if (!args.laneId?.trim()) throw new Error("Lane context requires laneId."); - return await packService.getLaneExport({ laneId: args.laneId.trim(), level }); - } - if (args.scope === "conflict") { - if (!args.laneId?.trim()) throw new Error("Conflict context requires laneId."); - return await packService.getConflictExport({ laneId: args.laneId.trim(), level }); - } - if (args.scope === "plan") { - if (!args.laneId?.trim()) throw new Error("Plan context requires laneId."); - return await packService.getPlanExport({ laneId: args.laneId.trim(), level }); - } - if (args.scope === "feature") { - if (!args.featureKey?.trim()) throw new Error("Feature context requires featureKey."); - return await packService.getFeatureExport({ featureKey: args.featureKey.trim(), level }); - } - if (!args.missionId?.trim()) throw new Error("Mission context requires missionId."); - return await packService.getMissionExport({ missionId: args.missionId.trim(), level }); - })(); - - content = exportResult.content; - truncated = exportResult.truncated; - - if (content.length > MAX_CHARS) { - content = content.slice(0, MAX_CHARS); - truncated = true; - } - } catch (error) { - content = `Failed to fetch ${args.scope} context: ${error instanceof Error ? error.message : String(error)}`; - } - - return { scope: args.scope, content, truncated }; - }; - const listSubagents = ({ sessionId }: AgentChatSubagentListArgs): AgentChatSubagentSnapshot[] => { return getTrackedSubagents(sessionId); }; @@ -5721,9 +6141,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 +6192,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))); @@ -5774,8 +6219,6 @@ export function createAgentChatService(args: { disposeAll, updateSession, warmupModel, - listContextPacks, - fetchContextPack, changePermissionMode, listSubagents, getSessionCapabilities, diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index c35d37e5..20ce4a4e 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -1193,10 +1193,7 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { defaultExecutionPolicy as NonNullable["defaultExecutionPolicy"]>; } - const defaultPlannerProvider = asString(orchestratorRaw.defaultPlannerProvider)?.trim(); - if (defaultPlannerProvider === "auto" || defaultPlannerProvider === "claude" || defaultPlannerProvider === "codex") { - orchestrator.defaultPlannerProvider = defaultPlannerProvider; - } + // Legacy defaultPlannerProvider is ignored -- use defaultOrchestratorModel instead. const autoResolveInterventions = asBool(orchestratorRaw.autoResolveInterventions); if (autoResolveInterventions != null) orchestrator.autoResolveInterventions = autoResolveInterventions; @@ -2477,7 +2474,6 @@ function validateEffectiveConfig( const ap = `${p}.legacy.actions[${actionIdx}]`; const type = action.type as AutomationActionType; if ( - type !== "update-packs" && type !== "predict-conflicts" && type !== "run-tests" && type !== "run-command" diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index 3a7f8776..ea44b8ea 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -73,7 +73,7 @@ import type { createSessionService } from "../sessions/sessionService"; import { normalizeConflictType, runGit, runGitMergeTree, runGitOrThrow } from "../git/git"; import { redactSecretsDeep } from "../../utils/redaction"; import { extractFirstJsonObject } from "../ai/utils"; -import { safeSegment } from "../packs/packUtils"; +import { safeSegment } from "../shared/packLegacyUtils"; import { asString, isRecord, parseDiffNameOnly, safeJsonParse, uniqueSorted } from "../shared/utils"; type PredictionStatus = "clean" | "conflict" | "unknown"; diff --git a/apps/desktop/src/main/services/packs/projectPackBuilder.ts b/apps/desktop/src/main/services/context/contextDocBuilder.ts similarity index 99% rename from apps/desktop/src/main/services/packs/projectPackBuilder.ts rename to apps/desktop/src/main/services/context/contextDocBuilder.ts index 6064d0f7..3bbbbcdf 100644 --- a/apps/desktop/src/main/services/packs/projectPackBuilder.ts +++ b/apps/desktop/src/main/services/context/contextDocBuilder.ts @@ -28,7 +28,7 @@ import { isRecord, asString, readFileIfExists -} from "./packUtils"; +} from "../shared/packLegacyUtils"; // ── Constants ──────────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/main/services/context/contextDocService.test.ts b/apps/desktop/src/main/services/context/contextDocService.test.ts index b5a875b1..9bd020d3 100644 --- a/apps/desktop/src/main/services/context/contextDocService.test.ts +++ b/apps/desktop/src/main/services/context/contextDocService.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { openKvDb } from "../state/kvDb"; -vi.mock("../packs/projectPackBuilder", () => ({ +vi.mock("./contextDocBuilder", () => ({ readContextDocMeta: vi.fn(() => ({ contextFingerprint: "fingerprint", contextVersion: 1, @@ -44,7 +44,7 @@ import { createContextDocService } from "./contextDocService"; import { resolveContextDocPath, runContextDocGeneration, -} from "../packs/projectPackBuilder"; +} from "./contextDocBuilder"; function createLogger() { return { diff --git a/apps/desktop/src/main/services/context/contextDocService.ts b/apps/desktop/src/main/services/context/contextDocService.ts index 8407a8be..21696140 100644 --- a/apps/desktop/src/main/services/context/contextDocService.ts +++ b/apps/desktop/src/main/services/context/contextDocService.ts @@ -8,7 +8,7 @@ import { readContextStatus as readContextStatusImpl, resolveContextDocPath as resolveContextDocPathImpl, runContextDocGeneration as runContextDocGenerationImpl, -} from "../packs/projectPackBuilder"; +} from "./contextDocBuilder"; import { getErrorMessage, nowIso, toOptionalString } from "../shared/utils"; import type { AdeDb } from "../state/kvDb"; import type { 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/cto/workerAgentService.ts b/apps/desktop/src/main/services/cto/workerAgentService.ts index fc66ce05..f509031f 100644 --- a/apps/desktop/src/main/services/cto/workerAgentService.ts +++ b/apps/desktop/src/main/services/cto/workerAgentService.ts @@ -335,9 +335,6 @@ function normalizeAdapterConfig(adapterType: AdapterType, config: Record; missionBudgetService: ReturnType; aiOrchestratorService: ReturnType; - packService: ReturnType; contextDocService?: ContextDocService | null; projectConfigService: ReturnType; processService: ReturnType; @@ -735,22 +729,6 @@ function getUnavailableAiStatus(): AiSettingsStatus { } -async function safeRefreshMissionPack( - ctx: AppContext, - missionId: string, - reason: string, -): Promise { - try { - await ctx.packService.refreshMissionPack({ missionId, reason }); - } catch (error) { - ctx.logger.warn("packs.refresh_mission_pack_failed", { - missionId, - reason, - error: getErrorMessage(error) - }); - } -} - function normalizeAutopilotExecutor(value: unknown): OrchestratorExecutorKind { const raw = typeof value === "string" ? value.trim() : ""; if (raw === "shell" || raw === "manual" || raw === "unified") return raw; @@ -2405,8 +2383,6 @@ export function registerIpc({ autopilotExecutor: defaultExecutorKind }); - await safeRefreshMissionPack(ctx, created.id, "mission_created"); - void (async () => { try { triggerAutoContextDocs(ctx, { @@ -2468,8 +2444,6 @@ export function registerIpc({ launchMode: "manual", autostart: false, }); - await safeRefreshMissionPack(ctx, created.id, "mission_created"); - const detail = ctx.missionService.get(created.id); if (detail) return detail; return created; @@ -2478,7 +2452,6 @@ export function registerIpc({ ipcMain.handle(IPC.missionsUpdate, async (_event, arg: UpdateMissionArgs): Promise => { const ctx = getCtx(); const updated = ctx.missionService.update(arg); - await safeRefreshMissionPack(ctx, updated.id, "mission_updated"); return updated; }); @@ -2495,14 +2468,12 @@ export function registerIpc({ ipcMain.handle(IPC.missionsUpdateStep, async (_event, arg: UpdateMissionStepArgs): Promise => { const ctx = getCtx(); const updated = ctx.missionService.updateStep(arg); - await safeRefreshMissionPack(ctx, updated.missionId, "mission_step_updated"); return updated; }); ipcMain.handle(IPC.missionsAddArtifact, async (_event, arg: AddMissionArtifactArgs): Promise => { const ctx = getCtx(); const artifact = ctx.missionService.addArtifact(arg); - await safeRefreshMissionPack(ctx, artifact.missionId, "mission_artifact_added"); return artifact; }); @@ -2511,7 +2482,6 @@ export function registerIpc({ async (_event, arg: AddMissionInterventionArgs): Promise => { const ctx = getCtx(); const intervention = ctx.missionService.addIntervention(arg); - await safeRefreshMissionPack(ctx, intervention.missionId, "mission_intervention_added"); return intervention; } ); @@ -2521,7 +2491,6 @@ export function registerIpc({ async (_event, arg: ResolveMissionInterventionArgs): Promise => { const ctx = getCtx(); const intervention = ctx.missionService.resolveIntervention(arg); - await safeRefreshMissionPack(ctx, intervention.missionId, "mission_intervention_resolved"); return intervention; } ); @@ -3724,16 +3693,6 @@ export function registerIpc({ return ctx.agentChatService.warmupModel(arg); }); - ipcMain.handle(IPC.agentChatListContextPacks, async (_event, arg: ContextPackListArgs = {}): Promise => { - const ctx = getCtx(); - return ctx.agentChatService.listContextPacks(arg); - }); - - ipcMain.handle(IPC.agentChatFetchContextPack, async (_event, arg: ContextPackFetchArgs): Promise => { - const ctx = getCtx(); - return ctx.agentChatService.fetchContextPack(arg); - }); - ipcMain.handle(IPC.agentChatChangePermissionMode, async (_event, arg: AgentChatChangePermissionModeArgs): Promise => { const ctx = getCtx(); ctx.agentChatService.changePermissionMode(arg); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index ef845ffb..dceb9019 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,61 @@ export function createLaneService({ return await listLanes(args); }, - async refreshSnapshots(args: ListLanesArgs = {}): Promise<{ refreshedCount: number }> { + getStateSnapshot(laneId: string): LaneStateSnapshotSummary | null { + const row = db.get( + ` + select s.lane_id, s.agent_summary_json, s.mission_summary_json, s.updated_at + from lane_state_snapshots s + join lanes l on l.id = s.lane_id + where s.lane_id = ? + and l.project_id = ? + limit 1 + `, + [laneId, projectId], + ); + 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 s.lane_id, s.agent_summary_json, s.mission_summary_json, s.updated_at + from lane_state_snapshots s + join lanes l on l.id = s.lane_id + where l.project_id = ? + `, + [projectId], + ).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[] }> { + invalidateLaneListCache(); 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 +821,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..aaf625ac 100644 --- a/apps/desktop/src/main/services/memory/hybridSearchService.test.ts +++ b/apps/desktop/src/main/services/memory/hybridSearchService.test.ts @@ -1,8 +1,42 @@ 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"; + +type DatabaseSyncLike = { + exec: (sql: string) => void; + close: () => void; +}; + +type DatabaseSyncCtor = new (path: string) => DatabaseSyncLike; + +function loadDatabaseSync(): DatabaseSyncCtor | null { + try { + const req = createRequire(import.meta.url); + return (req("node:sqlite") as { DatabaseSync?: DatabaseSyncCtor }).DatabaseSync ?? null; + } catch { + return null; + } +} + +function hasFts(): boolean { + const DatabaseSync = loadDatabaseSync(); + if (!DatabaseSync) return false; + let tmp: DatabaseSyncLike | null = null; + try { + tmp = new DatabaseSync(":memory:"); + 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 +227,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 +308,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 +394,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 +432,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 +444,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 +523,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 +553,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 +615,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 +633,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 +660,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/memory/memoryLifecycleService.test.ts b/apps/desktop/src/main/services/memory/memoryLifecycleService.test.ts index f5a20a01..36fd8f52 100644 --- a/apps/desktop/src/main/services/memory/memoryLifecycleService.test.ts +++ b/apps/desktop/src/main/services/memory/memoryLifecycleService.test.ts @@ -334,6 +334,7 @@ describe("memoryLifecycleService", () => { it("enforces the project scope hard limit by archiving the lowest-scoring tier 3 entry", async () => { const fixture = await createFixture(); let lowestId = ""; + fixture.db.run("BEGIN"); for (let index = 0; index < 2001; index += 1) { const id = insertMemory(fixture.db, fixture.now, { scope: "project", @@ -346,17 +347,19 @@ describe("memoryLifecycleService", () => { }); if (index === 0) lowestId = id; } + fixture.db.run("COMMIT"); const result = await fixture.service.runSweep(); expect(result.entriesArchived).toBe(1); expect(getActiveScopeCount(fixture.db, "project")).toBe(2000); expect(getMemory(fixture.db, lowestId)?.status).toBe("archived"); - }); + }, 60_000); it("enforces the agent scope hard limit per scope owner", async () => { const fixture = await createFixture(); let lowestId = ""; + fixture.db.run("BEGIN"); for (let index = 0; index < 501; index += 1) { const id = insertMemory(fixture.db, fixture.now, { scope: "agent", @@ -367,6 +370,7 @@ describe("memoryLifecycleService", () => { }); if (index === 0) lowestId = id; } + fixture.db.run("COMMIT"); await fixture.service.runSweep(); diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts index 63882096..7b208759 100644 --- a/apps/desktop/src/main/services/missions/missionService.ts +++ b/apps/desktop/src/main/services/missions/missionService.ts @@ -5,6 +5,7 @@ import { isValidResolutionKind, createDefaultComputerUsePolicy, normalizeComputerUsePolicy, + TERMINAL_MISSION_STATUSES, } from "../../../shared/types"; import type { AddMissionArtifactArgs, @@ -26,7 +27,6 @@ import type { MissionLaneClaimCheckResult, MissionPhaseConfiguration, MissionPhaseOverride, - MissionPlannerRun, ListMissionsArgs, MissionArtifact, MissionArtifactType, @@ -60,7 +60,6 @@ import type { UpdateMissionStepArgs } from "../../../shared/types"; import type { AdeDb } from "../state/kvDb"; -/** Inline type — formerly in the deleted missionPlanningService module. */ type MissionPlanStepDraft = { index: number; title: string; @@ -81,8 +80,6 @@ import { normalizeAgentRuntimeFlags } from "../orchestrator/teamRuntimeConfig"; import { resolveModelDescriptor } from "../../../shared/modelRegistry"; import { normalizeMissionArtifactType as normalizeMissionArtifactTypeValue } from "../../../shared/proofArtifacts"; -const TERMINAL_MISSION_STATUSES = new Set(["completed", "failed", "canceled"]); - const ACTIVE_MISSION_STATUSES = new Set(["in_progress", "planning", "intervention_required"]); const DEFAULT_CONCURRENCY_CONFIG: MissionConcurrencyConfig = { @@ -240,7 +237,6 @@ type MissionPhaseOverrideRow = { type CreateMissionInternalArgs = CreateMissionArgs & { plannedSteps?: MissionPlanStepDraft[]; - plannerRun?: MissionPlannerRun | null; plannerPlan?: PlannerPlan | null; }; @@ -2429,7 +2425,6 @@ export function createMissionService({ const priority = args.priority ?? "normal"; const executionMode = args.executionMode ?? "local"; const targetMachineId = coerceNullableString(args.targetMachineId); - const plannerRun = args.plannerRun ?? null; const plannerPlan = args.plannerPlan ?? null; const launchMode = args.launchMode === "manual" ? "manual" : "autopilot"; const autostart = args.autostart !== false; @@ -2549,36 +2544,6 @@ export function createMissionService({ phaseCount: selectedPhases.length, phases: selectedPhases }, - ...(plannerRun - ? { - planner: { - id: plannerRun.id, - requestedEngine: plannerRun.requestedEngine, - resolvedEngine: plannerRun.resolvedEngine, - status: plannerRun.status, - degraded: plannerRun.degraded, - reasonCode: plannerRun.reasonCode, - reasonDetail: plannerRun.reasonDetail, - planHash: plannerRun.planHash, - normalizedPlanHash: plannerRun.normalizedPlanHash, - commandPreview: plannerRun.commandPreview, - rawResponse: truncateForMetadata(plannerRun.rawResponse, 200_000), - durationMs: plannerRun.durationMs, - validationErrors: plannerRun.validationErrors, - attempts: plannerRun.attempts.map((attempt) => ({ - id: attempt.id, - engine: attempt.engine, - status: attempt.status, - reasonCode: attempt.reasonCode, - detail: attempt.detail, - commandPreview: attempt.commandPreview, - rawResponse: truncateForMetadata(attempt.rawResponse, 50_000), - validationErrors: attempt.validationErrors, - createdAt: attempt.createdAt - })) - } - } - : {}), plannerPlan: plannerPlan ? { schemaVersion: plannerPlan.schemaVersion, @@ -2684,13 +2649,13 @@ export function createMissionService({ executionMode, targetMachineId, preview: summarizePrompt(prompt), - plannerVersion: plannerRun ? "ade.missionPlanner.v2" : "coordinator", + plannerVersion: "coordinator", plannerStrategy: plannerPlan?.missionSummary.strategy ?? "coordinator", plannerStepCount: stepsToPersist.length, plannerKeywords: [], - plannerEngineRequested: plannerRun?.requestedEngine ?? args.plannerEngine ?? "auto", - plannerEngineResolved: plannerRun?.resolvedEngine ?? null, - plannerDegraded: plannerRun?.degraded ?? false, + plannerEngineRequested: args.plannerEngine ?? "auto", + plannerEngineResolved: null, + plannerDegraded: false, phaseProfileId: selectedProfile?.id ?? null, phaseKeys: selectedPhases.map((phase) => phase.phaseKey) } @@ -2708,38 +2673,6 @@ export function createMissionService({ } }); - if (plannerRun) { - recordEvent({ - missionId: id, - eventType: "mission_plan_generated", - actor: "system", - summary: `Planner completed with ${plannerRun.resolvedEngine ?? "unknown"}.`, - payload: { - plannerRunId: plannerRun.id, - requestedEngine: plannerRun.requestedEngine, - resolvedEngine: plannerRun.resolvedEngine, - status: plannerRun.status, - degraded: plannerRun.degraded, - reasonCode: plannerRun.reasonCode, - reasonDetail: plannerRun.reasonDetail, - planHash: plannerRun.planHash, - normalizedPlanHash: plannerRun.normalizedPlanHash, - commandPreview: plannerRun.commandPreview, - rawResponse: truncateForMetadata(plannerRun.rawResponse, 8_000), - durationMs: plannerRun.durationMs, - validationErrors: plannerRun.validationErrors, - attempts: plannerRun.attempts.map((attempt) => ({ - id: attempt.id, - engine: attempt.engine, - status: attempt.status, - reasonCode: attempt.reasonCode, - detail: attempt.detail, - createdAt: attempt.createdAt - })) - } - }); - } - if (plannerPlan) { const planSummaryText = [ plannerPlan.missionSummary?.strategy ? `Strategy: ${plannerPlan.missionSummary.strategy}` : null, @@ -2766,21 +2699,6 @@ export function createMissionService({ } } - if (plannerRun?.rawResponse?.trim()) { - insertArtifact({ - missionId: id, - artifactType: "note", - title: "Planner output", - description: truncateForMetadata(plannerRun.rawResponse, 20_000), - createdBy: "system", - metadata: { - plannerRunId: plannerRun.id, - resolvedEngine: plannerRun.resolvedEngine, - source: "planner_run", - }, - }); - } - db.flushNow(); emit({ missionId: id, reason: "created" }); const detail = this.get(id); diff --git a/apps/desktop/src/main/services/missions/phaseEngine.ts b/apps/desktop/src/main/services/missions/phaseEngine.ts index a5f3b11e..af87130a 100644 --- a/apps/desktop/src/main/services/missions/phaseEngine.ts +++ b/apps/desktop/src/main/services/missions/phaseEngine.ts @@ -8,7 +8,6 @@ import type { MissionPhaseConfiguration, } from "../../../shared/types"; import { getDefaultModelDescriptor } from "../../../shared/modelRegistry"; -/** Inline type — formerly in the deleted missionPlanningService module. */ type MissionPlanStepDraft = { index: number; title: string; diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index b7e7acbb..6cc44ba4 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -1050,7 +1050,6 @@ async function createFixture(args: { db, projectId, projectRoot, - packService, projectConfigService }); const defaultUnifiedModelId = "anthropic/claude-sonnet-4-6"; diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index fec48b30..edbc92fa 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -258,7 +258,7 @@ import { } from "../../../shared/proofArtifacts"; import { getCapabilityForRequirement } from "../computerUse/localComputerUse"; -// ── Intervention Prompt Builder (inlined from deleted planningPipeline module) ── +// ── Intervention Prompt Builder ── function buildInterventionResolverPrompt(args: { missionTitle: string; @@ -4796,11 +4796,11 @@ Check all worker statuses and continue managing the mission from here. Read work } const config = readConfig(projectConfigService); - if (config.defaultPlannerProvider === "claude" || config.defaultPlannerProvider === "codex") { - return config.defaultPlannerProvider as "claude" | "codex"; + if (config.defaultOrchestratorModelId === "claude" || config.defaultOrchestratorModelId === "codex") { + return config.defaultOrchestratorModelId as "claude" | "codex"; } - if (config.defaultPlannerProvider) { - const desc = getModelById(config.defaultPlannerProvider); + if (config.defaultOrchestratorModelId) { + const desc = getModelById(config.defaultOrchestratorModelId); if (desc?.family === "anthropic") return "claude"; if (desc?.family === "openai") return "codex"; } diff --git a/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts b/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts index d0994ba3..01c23499 100644 --- a/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts +++ b/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts @@ -100,7 +100,6 @@ async function createFixture() { db, projectId, projectRoot, - packService, conflictService: undefined, ptyService, projectConfigService: null as any, diff --git a/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts b/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts index ad2a3a8b..91ec8cbb 100644 --- a/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts @@ -327,15 +327,6 @@ describe("missionBudgetService", () => { db, projectId, projectRoot: root, - packService: { - getLaneExport: async ({ laneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) => - buildExport(`lane:${laneId}`, "lane", level), - refreshLanePack: async () => {}, - getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) => - buildExport("project", "project", level), - getDeltaDigest: async () => null, - getHeadVersion: () => ({ versionId: "pack-v1", versionNumber: 1 }) - } as any, }); const started = orchestratorService.startRun({ diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts index f4185892..a14e3017 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts @@ -168,7 +168,8 @@ export type OrchestratorHookCommandRunner = (args: { }) => Promise; export type ResolvedOrchestratorConfig = { - defaultPlannerProvider: string | null; + /** Resolved model ID for the orchestrator (from defaultOrchestratorModel). */ + defaultOrchestratorModelId: string | null; defaultExecutionPolicy: Partial | null; defaultMissionLevelSettings: MissionLevelSettings | null; hooks: ResolvedOrchestratorHooks; @@ -901,9 +902,11 @@ export function readConfig(projectConfigService: ReturnType) : {}; - const defaultPlannerProviderRaw = asString(orchestrator.defaultPlannerProvider); - const defaultPlannerProvider: string | null = - defaultPlannerProviderRaw && defaultPlannerProviderRaw.trim().length > 0 ? defaultPlannerProviderRaw.trim() : null; + // Prefer defaultOrchestratorModel.modelId; fall back to legacy defaultPlannerProvider. + const orcModel = isRecord(orchestrator.defaultOrchestratorModel) ? orchestrator.defaultOrchestratorModel : null; + const orcModelId = orcModel && typeof orcModel.modelId === "string" ? orcModel.modelId.trim() : null; + const legacyProvider = asString(orchestrator.defaultPlannerProvider)?.trim() || null; + const defaultOrchestratorModelId = orcModelId || legacyProvider; const defaultExecutionPolicy = isRecord(orchestrator.defaultExecutionPolicy) ? (orchestrator.defaultExecutionPolicy as Partial) : null; @@ -912,7 +915,7 @@ export function readConfig(projectConfigService: ReturnType; projectConfigService?: Record | null; aiIntegrationService?: Record | null; memoryService?: Record | null; @@ -147,34 +146,10 @@ async function createFixture(args: { } } as any; - const packService = { - getLaneExport: async ({ laneId: targetLaneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) => - buildExport(`lane:${targetLaneId}`, "lane", level), - getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) => buildExport("project", "project", level), - refreshMissionPack: async ({ missionId: targetMissionId }: { missionId: string }) => ({ - packKey: `mission:${targetMissionId}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", targetMissionId, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${targetMissionId}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${targetMissionId}`, - metadata: null, - body: "# Mission Pack" - }) - } as any; - const service = createOrchestratorService({ db, projectId, projectRoot, - packService: { - ...packService, - ...(args.packService ?? {}) - } as any, conflictService: args.conflictService, ptyService, projectConfigService: (args.projectConfigService ?? null) as any, @@ -3378,43 +3353,6 @@ describe("orchestratorService", () => { } }); - it("creates context snapshots without lane pack bootstrap refresh", async () => { - let laneExportCalls = 0; - const fixture = await createFixture({ - packService: { - getLaneExport: async ({ laneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) => { - laneExportCalls += 1; - return buildExport(`lane:${laneId}`, "lane", level); - }, - refreshLanePack: async () => { - throw new Error("refreshLanePack should not be used for live context exports"); - } - } - }); - try { - const started = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "bootstrap-pack", title: "Bootstrap pack", stepIndex: 0, laneId: fixture.laneId }] - }); - const step = fixture.service.listSteps(started.run.id)[0]; - if (!step) throw new Error("Missing step"); - const attempt = await fixture.service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "owner" - }); - const snapshot = fixture.service - .listContextSnapshots({ runId: started.run.id }) - .find((entry) => entry.id === attempt.contextSnapshotId); - expect(attempt.contextSnapshotId).toBeTruthy(); - expect(laneExportCalls).toBe(1); - expect(snapshot?.cursor.contextSources?.some((source) => source.startsWith("context_export:project:"))).toBe(true); - expect(snapshot?.cursor.contextSources?.some((source) => source.startsWith("context_export:lane:"))).toBe(true); - } finally { - fixture.dispose(); - } - }); - it("normalizes adapter envelopes and supports deterministic integration chain blocking", async () => { const conflictService = { prepareResolverSession: async () => ({ diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 40ab40c7..44789540 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -76,7 +76,6 @@ import { import { resolveClaudeCliModel, resolveCodexCliModel } from "../ai/claudeModelUtils"; import { runGit } from "../git/git"; import type { AdeDb, SqlValue } from "../state/kvDb"; -import type { createPackService } from "../packs/packService"; import type { createPtyService } from "../pty/ptyService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createConflictService } from "../conflicts/conflictService"; @@ -97,7 +96,7 @@ import { parseAgentChatTranscript, } from "../../../shared/chatTranscript"; import { isWorkerBootstrapNoiseLine } from "../../../shared/workerRuntimeNoise"; -import { deriveSessionSummaryFromText } from "../packs/transcriptInsights"; +import { deriveSessionSummaryFromText } from "../shared/transcriptInsights"; import { type RunRow, type StepRow, type AttemptRow, type ClaimRow, type ContextSnapshotRow, type HandoffRow, type TimelineRow, @@ -140,8 +139,8 @@ import { resolveAdeLayout } from "../../../shared/adeLayout"; type CreateSnapshotResult = { snapshotId: string; cursor: OrchestratorContextSnapshotCursor; - laneExport: PackExport | null; - projectExport: PackExport; + laneExport: { content: string; truncated: boolean } | null; + projectExport: { content: string; truncated: boolean }; docsRefs: OrchestratorDocsRef[]; fullDocs: Array<{ path: string; content: string; truncated: boolean }>; }; @@ -196,8 +195,8 @@ export type OrchestratorExecutorStartArgs = { /** All steps in the run, for building a compact plan view in the worker prompt. */ allSteps: OrchestratorStep[]; contextProfile: OrchestratorContextPolicyProfile; - laneExport: PackExport | null; - projectExport: PackExport; + laneExport: { content: string; truncated: boolean } | null; + projectExport: { content: string; truncated: boolean }; docsRefs: OrchestratorDocsRef[]; fullDocs: Array<{ path: string; content: string; truncated: boolean }>; createTrackedSession: (args: Omit & { tracked?: boolean }) => Promise<{ ptyId: string; sessionId: string }>; @@ -367,6 +366,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 ( @@ -791,7 +793,6 @@ export function createOrchestratorService({ db, projectId, projectRoot, - packService, conflictService, ptyService, agentChatService, @@ -810,7 +811,6 @@ export function createOrchestratorService({ db: AdeDb; projectId: string; projectRoot: string; - packService: ReturnType; conflictService?: ReturnType; ptyService?: ReturnType; agentChatService?: ReturnType | null; @@ -2505,15 +2505,8 @@ export function createOrchestratorService({ const laneExportLevel = args.contextProfile.laneExportLevel; const projectExportLevel = stepType === "analysis" ? "standard" : args.contextProfile.projectExportLevel; const lanePackKey = args.step.laneId ? `lane:${args.step.laneId}` : null; - const laneExport = args.step.laneId - ? await packService.getLaneExport({ - laneId: args.step.laneId, - level: laneExportLevel - }) - : null; - const projectExport = await packService.getProjectExport({ - level: projectExportLevel - }); + const laneExport: { content: string; truncated: boolean } | null = null; + const projectExport: { content: string; truncated: boolean } = { content: "", truncated: false }; const docsPaths = readDocPaths(projectRoot); let remainingBytes = args.contextProfile.maxDocBytes; @@ -2895,16 +2888,16 @@ export function createOrchestratorService({ docsRefsOnly: deepPackV2.docsRefsOnly, packRefs: [ { - packKey: projectExport.packKey, - level: projectExport.level, - approxTokens: projectExport.approxTokens ?? null + packKey: "project", + level: projectExportLevel, + approxTokens: null }, - ...(laneExport + ...(lanePackKey ? [ { - packKey: laneExport.packKey, - level: laneExport.level, - approxTokens: laneExport.approxTokens ?? null + packKey: lanePackKey, + level: laneExportLevel, + approxTokens: null } ] : []) @@ -2937,21 +2930,15 @@ export function createOrchestratorService({ l2: memoryL2 }; - const laneVersionId = - laneExport?.header.versionId ?? - laneExport?.header.contentHash ?? - (laneExport ? `live:${laneExport.packKey}:${sha256(laneExport.content)}` : null); - const projectVersionId = - projectExport.header.versionId ?? - projectExport.header.contentHash ?? - `live:${projectExport.packKey}:${sha256(projectExport.content)}`; + const laneVersionId = laneExport ? `live:${lanePackKey ?? "lane"}:${sha256(Buffer.from(laneExport.content))}` : null; + const projectVersionId = `live:project:${sha256(Buffer.from(projectExport.content))}`; const cursor: OrchestratorContextSnapshotCursor = { lanePackKey, lanePackVersionId: laneVersionId, - lanePackVersionNumber: laneExport?.header.versionNumber ?? null, + lanePackVersionNumber: null, projectPackKey: "project", projectPackVersionId: projectVersionId, - projectPackVersionNumber: projectExport.header.versionNumber ?? null, + projectPackVersionNumber: null, packDeltaSince: previousPackDeltaSince, docs: docsRefs, packDeltaDigest, @@ -2966,8 +2953,8 @@ export function createOrchestratorService({ "execution_pack_v2", "deep_pack_v2", "memory_hierarchy_v1", - `context_export:project:${projectExport.level}`, - ...(lanePackKey ? [`context_export:${lanePackKey}:${laneExport?.level ?? laneExportLevel}`] : []), + `context_export:project:${projectExportLevel}`, + ...(lanePackKey ? [`context_export:${lanePackKey}:${laneExportLevel}`] : []), ...(packDeltaDigest ? ["delta_digest"] : []), ...(missionHandoffIds.length ? ["mission_handoffs"] : []), ...(missionHandoffDigest ? ["mission_handoff_digest"] : []), @@ -3822,16 +3809,16 @@ export function createOrchestratorService({ packs: { lane: args.laneExport ? { - packKey: args.laneExport.packKey, - level: args.laneExport.level, - approxTokens: args.laneExport.approxTokens, + packKey: args.step.laneId ? `lane:${args.step.laneId}` : null, + level: args.contextProfile.laneExportLevel, + approxTokens: null, contentPreview: clipText(args.laneExport.content, 3_000) } : null, project: { - packKey: args.projectExport.packKey, - level: args.projectExport.level, - approxTokens: args.projectExport.approxTokens, + packKey: "project", + level: args.contextProfile.projectExportLevel, + approxTokens: null, contentPreview: clipText(args.projectExport.content, 2_000) } }, @@ -6586,8 +6573,8 @@ export function createOrchestratorService({ contextSnapshotId: snapshot.snapshotId, docsMode: contextPolicy.docsMode, docsRefs: snapshot.docsRefs, - laneExportLevel: snapshot.laneExport?.level ?? null, - projectExportLevel: snapshot.projectExport.level, + laneExportLevel: contextPolicy.laneExportLevel ?? null, + projectExportLevel: contextPolicy.projectExportLevel, claims: acquiredClaims.map((claim) => ({ id: claim.id, scopeKind: claim.scopeKind, @@ -7310,7 +7297,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 +7630,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/orchestrator/orchestratorSmoke.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts index 5ed66dd5..93dc6df3 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts @@ -358,69 +358,10 @@ async function createSmokeFixture() { }) } as any; - const packService = { - getLaneExport: async ({ laneId: targetLaneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) => - buildExport(`lane:${targetLaneId}`, "lane", level), - getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) => buildExport("project", "project", level), - getHeadVersion: ({ packKey }: { packKey: string }) => ({ - packKey, - packType: packKey.startsWith("lane:") ? "lane" : "project", - versionId: `${packKey}-v1`, - versionNumber: 1, - contentHash: `hash-${packKey}`, - updatedAt: now - }), - getDeltaDigest: async (): Promise => ({ - packKey: `lane:${laneId}`, - packType: "lane", - since: { - sinceVersionId: null, - sinceTimestamp: now, - baselineVersionId: null, - baselineVersionNumber: null, - baselineCreatedAt: null - }, - newVersion: { - packKey: `lane:${laneId}`, - packType: "lane", - versionId: `lane:${laneId}-v1`, - versionNumber: 1, - contentHash: "hash", - updatedAt: now - }, - changedSections: [], - highImpactEvents: [], - blockers: [], - conflicts: null, - decisionState: { - recommendedExportLevel: "standard", - reasons: [] - }, - handoffSummary: "none", - clipReason: null, - omittedSections: null - }), - refreshMissionPack: async ({ missionId }: { missionId: string }) => ({ - packKey: `mission:${missionId}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", missionId, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${missionId}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${missionId}`, - metadata: null, - body: "# Mission Pack" - }) - } as any; - const orchestratorService = createOrchestratorService({ db, projectId, projectRoot, - packService, projectConfigService }); const aiOrchestratorService = createAiOrchestratorService({ @@ -672,64 +613,6 @@ describe("orchestrator smoke", () => { }) } as any; - const packService = { - getLaneExport: async ({ laneId: targetLaneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) => - buildExport(`lane:${targetLaneId}`, "lane", level), - getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) => buildExport("project", "project", level), - getHeadVersion: ({ packKey }: { packKey: string }) => ({ - packKey, - packType: packKey.startsWith("lane:") ? "lane" : "project", - versionId: `${packKey}-v1`, - versionNumber: 1, - contentHash: `hash-${packKey}`, - updatedAt: now - }), - getDeltaDigest: async (): Promise => ({ - packKey: `lane:${laneId}`, - packType: "lane", - since: { - sinceVersionId: null, - sinceTimestamp: now, - baselineVersionId: null, - baselineVersionNumber: null, - baselineCreatedAt: null - }, - newVersion: { - packKey: `lane:${laneId}`, - packType: "lane", - versionId: `lane:${laneId}-v1`, - versionNumber: 1, - contentHash: "hash", - updatedAt: now - }, - changedSections: [], - highImpactEvents: [], - blockers: [], - conflicts: null, - decisionState: { - recommendedExportLevel: "standard", - reasons: [] - }, - handoffSummary: "none", - clipReason: null, - omittedSections: null - }), - refreshMissionPack: async ({ missionId }: { missionId: string }) => ({ - packKey: `mission:${missionId}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", missionId, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${missionId}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${missionId}`, - metadata: null, - body: "# Mission Pack" - }) - } as any; - const complexPlan = { schemaVersion: "1.0", missionSummary: { @@ -952,7 +835,6 @@ describe("orchestrator smoke", () => { db, projectId, projectRoot, - packService, projectConfigService }); const aiOrchestratorService = createAiOrchestratorService({ diff --git a/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts b/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts index 4076b1cc..d82794ec 100644 --- a/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts +++ b/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts @@ -82,27 +82,6 @@ async function createFixture() { ] ); - const packService = { - getLaneExport: async ({ laneId: lid, level }: { laneId: string; level: string }) => - buildExport(`lane:${lid}`, "lane", level as any), - getProjectExport: async ({ level }: { level: string }) => - buildExport("project", "project", level as any), - refreshMissionPack: async () => ({ - packKey: "mission:pack", - packType: "mission", - path: path.join(projectRoot, ".ade", "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: "v1", - versionNumber: 1, - contentHash: "hash", - metadata: null, - body: "# Mission Pack", - }), - } as any; - const ptyService = { create: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), } as any; @@ -111,7 +90,6 @@ async function createFixture() { db, projectId, projectRoot, - packService, ptyService, projectConfigService: null as any, aiIntegrationService: null as any, diff --git a/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts b/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts index 3c53c49f..6f0c9cf1 100644 --- a/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts +++ b/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts @@ -439,48 +439,6 @@ describe("VAL-PLAN-004: planner contract enforcement", () => { db, projectId, projectRoot, - packService: { - getLaneExport: vi.fn(async () => ({ - packKey: `lane:${laneId}`, - packType: "lane", - level: "standard", - header: {} as any, - content: "lane", - approxTokens: 32, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null, - })), - getProjectExport: vi.fn(async () => ({ - packKey: "project", - packType: "project", - level: "standard", - header: {} as any, - content: "project", - approxTokens: 32, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null, - })), - refreshMissionPack: vi.fn(async () => ({ - packKey: `mission:${missionId}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", missionId, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${missionId}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${missionId}`, - metadata: null, - body: "# Mission Pack", - })), - } as any, ptyService: { create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })), } as any, diff --git a/apps/desktop/src/main/services/orchestrator/qualityGateService.ts b/apps/desktop/src/main/services/orchestrator/qualityGateService.ts deleted file mode 100644 index 1f933b67..00000000 --- a/apps/desktop/src/main/services/orchestrator/qualityGateService.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * qualityGateService.ts - * - * Quality evaluation: evaluateQualityGateForStep, handleQualityGateFailure, - * quality gate helpers. - * - * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes. - */ - -import { - GATE_PHASE_STEP_TYPES, - QUALITY_GATE_MAX_OUTPUT_CHARS, -} from "./orchestratorContext"; - -/** - * Check whether a step type should trigger quality gate evaluation. - */ -export function isGatePhaseStepType(stepType: string): boolean { - return GATE_PHASE_STEP_TYPES.has(stepType.toLowerCase()); -} - -/** - * Clip output for quality gate context. - */ -export function clipForQualityGate(output: string): string { - if (output.length <= QUALITY_GATE_MAX_OUTPUT_CHARS) return output; - return output.slice(0, QUALITY_GATE_MAX_OUTPUT_CHARS - 15) + "... (truncated)"; -} diff --git a/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts b/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts index 70342351..696173a1 100644 --- a/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts +++ b/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts @@ -125,7 +125,6 @@ async function createFixture(opts?: { projectConfigService?: any }) { db, projectId, projectRoot, - packService, ptyService, projectConfigService: opts?.projectConfigService ?? null as any, aiIntegrationService: null as any, diff --git a/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts b/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts index 56aaf790..38216df7 100644 --- a/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts +++ b/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts @@ -122,7 +122,6 @@ async function createFixture() { db, projectId, projectRoot, - packService, ptyService, projectConfigService: null as any, aiIntegrationService: null as any, diff --git a/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts b/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts index c28e83de..7996592d 100644 --- a/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts +++ b/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts @@ -121,7 +121,6 @@ async function createFixture(args: { db, projectId, projectRoot, - packService, ptyService, projectConfigService: null as any, aiIntegrationService: (args.aiIntegrationService ?? null) as any, diff --git a/apps/desktop/src/main/services/packs/conflictPackBuilder.ts b/apps/desktop/src/main/services/packs/conflictPackBuilder.ts deleted file mode 100644 index 5bf61275..00000000 --- a/apps/desktop/src/main/services/packs/conflictPackBuilder.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Conflict pack builder — generates conflict packs by running merge-tree - * analysis and computing overlap. Also provides helpers for reading - * conflict prediction packs and deriving conflict state. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { runGit, runGitMergeTree, runGitOrThrow } from "../git/git"; -import type { createLaneService } from "../lanes/laneService"; -import type { - GitConflictState, - LaneSummary, - LaneLineageV1, - PackConflictStateV1 -} from "../../../shared/types"; -import { parseDiffNameOnly, uniqueSorted } from "../shared/utils"; -import { - asString, - isRecord, - normalizeConflictStatus, - type ConflictPredictionPackFile -} from "./packUtils"; - -// ── Deps ───────────────────────────────────────────────────────────────────── - -export type ConflictPackBuilderDeps = { - projectRoot: string; - laneService: ReturnType; - getConflictPredictionPath: (laneId: string) => string; - getLanePackPath: (laneId: string) => string; -}; - -// ── Conflict prediction reading ────────────────────────────────────────────── - -export function readConflictPredictionPack( - deps: ConflictPackBuilderDeps, - laneId: string -): ConflictPredictionPackFile | null { - const filePath = deps.getConflictPredictionPath(laneId); - if (!fs.existsSync(filePath)) return null; - try { - const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (!isRecord(parsed)) return null; - return parsed as ConflictPredictionPackFile; - } catch { - return null; - } -} - -// ── Git conflict state ────────────────────────────────────────────────────── - -export async function readGitConflictState( - deps: ConflictPackBuilderDeps, - laneId: string -): Promise { - const lane = deps.laneService.getLaneBaseAndBranch(laneId); - const gitDirRes = await runGit(["rev-parse", "--absolute-git-dir"], { cwd: lane.worktreePath, timeoutMs: 10_000 }); - const gitDir = gitDirRes.exitCode === 0 ? gitDirRes.stdout.trim() : ""; - const hasRebase = - gitDir.length > 0 && - (fs.existsSync(path.join(gitDir, "rebase-apply")) || fs.existsSync(path.join(gitDir, "rebase-merge"))); - const hasMerge = gitDir.length > 0 && fs.existsSync(path.join(gitDir, "MERGE_HEAD")); - const kind: GitConflictState["kind"] = hasRebase ? "rebase" : hasMerge ? "merge" : null; - - const unmergedRes = await runGit(["diff", "--name-only", "--diff-filter=U"], { cwd: lane.worktreePath, timeoutMs: 10_000 }); - const conflictedFiles = - unmergedRes.exitCode === 0 ? parseDiffNameOnly(unmergedRes.stdout).sort((a, b) => a.localeCompare(b)) : []; - - const inProgress = kind != null; - return { - laneId, - kind, - inProgress, - conflictedFiles, - canContinue: inProgress && conflictedFiles.length === 0, - canAbort: inProgress - }; -} - -// ── Derive conflict state from prediction pack ────────────────────────────── - -export function deriveConflictStateForLane( - deps: ConflictPackBuilderDeps, - laneId: string -): PackConflictStateV1 | null { - const pack = readConflictPredictionPack(deps, laneId); - if (!pack || !isRecord(pack.status)) return null; - const status = pack.status as NonNullable; - const statusValue = normalizeConflictStatus(asString(status.status).trim()) ?? "unknown"; - const overlappingFileCount = Number(status.overlappingFileCount ?? 0); - const peerConflictCount = Number(status.peerConflictCount ?? 0); - const lastPredictedAt = asString(status.lastPredictedAt).trim() || null; - const strategy = asString(pack.strategy).trim() || undefined; - const pairwisePairsComputed = Number.isFinite(Number(pack.pairwisePairsComputed)) ? Number(pack.pairwisePairsComputed) : undefined; - const pairwisePairsTotal = Number.isFinite(Number(pack.pairwisePairsTotal)) ? Number(pack.pairwisePairsTotal) : undefined; - const lastRecomputedAt = asString(pack.lastRecomputedAt).trim() || asString(pack.generatedAt).trim() || null; - - return { - status: statusValue, - lastPredictedAt, - overlappingFileCount: Number.isFinite(overlappingFileCount) ? overlappingFileCount : 0, - peerConflictCount: Number.isFinite(peerConflictCount) ? peerConflictCount : 0, - unresolvedPairCount: Number.isFinite(peerConflictCount) ? peerConflictCount : 0, - truncated: Boolean(pack.truncated), - strategy, - pairwisePairsComputed, - pairwisePairsTotal, - lastRecomputedAt - }; -} - -// ── Lane lineage ──────────────────────────────────────────────────────────── - -export function computeLaneLineage(args: { laneId: string; lanesById: Map }): LaneLineageV1 { - const lane = args.lanesById.get(args.laneId) ?? null; - const stackDepth = Number(lane?.stackDepth ?? 0); - const parentLaneId = lane?.parentLaneId ?? null; - let baseLaneId: string | null = lane?.id ?? args.laneId; - let cursor = lane; - const visited = new Set(); - while (cursor?.parentLaneId && !visited.has(cursor.id)) { - visited.add(cursor.id); - const parent = args.lanesById.get(cursor.parentLaneId) ?? null; - if (!parent) break; - baseLaneId = parent.id; - cursor = parent; - } - return { - laneId: args.laneId, - parentLaneId, - baseLaneId, - stackDepth: Number.isFinite(stackDepth) ? stackDepth : 0 - }; -} - -// ── Conflict risk summary lines ───────────────────────────────────────────── - -export function buildLaneConflictRiskSummaryLines( - deps: ConflictPackBuilderDeps, - laneId: string -): string[] { - const pack = readConflictPredictionPack(deps, laneId); - if (!pack || !isRecord(pack.status)) return []; - - const status = pack.status as NonNullable; - const statusValue = asString(status.status).trim() || "unknown"; - const overlappingFileCount = Number(status.overlappingFileCount ?? 0); - const peerConflictCount = Number(status.peerConflictCount ?? 0); - const lastPredictedAt = asString(status.lastPredictedAt).trim() || null; - - const lines: string[] = []; - lines.push(`- Conflict status: \`${statusValue}\``); - lines.push(`- Overlapping files: ${Number.isFinite(overlappingFileCount) ? overlappingFileCount : 0}`); - lines.push(`- Peer conflicts: ${Number.isFinite(peerConflictCount) ? peerConflictCount : 0}`); - if (lastPredictedAt) lines.push(`- Last predicted: ${lastPredictedAt}`); - if (asString(pack.generatedAt).trim()) lines.push(`- Generated: ${asString(pack.generatedAt).trim()}`); - - const overlaps = Array.isArray(pack.overlaps) ? pack.overlaps : []; - const riskScore = (riskLevel: string): number => { - const normalized = riskLevel.trim().toLowerCase(); - if (normalized === "high") return 3; - if (normalized === "medium") return 2; - if (normalized === "low") return 1; - if (normalized === "none") return 0; - return 0; - }; - - const peers = overlaps - .filter((ov) => ov && ov.peerId != null) - .map((ov) => { - const peerName = asString(ov.peerName).trim() || "Unknown lane"; - const riskLevel = asString(ov.riskLevel).trim() || "unknown"; - const fileCount = Array.isArray(ov.files) ? ov.files.length : 0; - return { peerName, riskLevel, fileCount, score: riskScore(riskLevel) }; - }) - .filter((ov) => ov.score > 0 || ov.fileCount > 0) - .sort((a, b) => b.score - a.score || b.fileCount - a.fileCount || a.peerName.localeCompare(b.peerName)) - .slice(0, 5); - - if (peers.length) { - lines.push("- Top risky peers:"); - for (const peer of peers) { - lines.push(` - ${peer.peerName}: \`${peer.riskLevel}\` (${peer.fileCount} files)`); - } - } - - if (pack.truncated) { - const strategyValue = asString(pack.strategy).trim() || "partial"; - const computed = Number(pack.pairwisePairsComputed ?? NaN); - const total = Number(pack.pairwisePairsTotal ?? NaN); - if (Number.isFinite(computed) && Number.isFinite(total) && total > 0) { - lines.push(`- Pairwise coverage: ${computed}/${total} pairs (strategy=\`${strategyValue}\`)`); - } else { - lines.push(`- Pairwise coverage: partial (strategy=\`${strategyValue}\`)`); - } - } - - return lines; -} - -// ── Read lane pack excerpt ────────────────────────────────────────────────── - -export function readLanePackExcerpt(deps: ConflictPackBuilderDeps, laneId: string): string | null { - const filePath = deps.getLanePackPath(laneId); - if (!fs.existsSync(filePath)) return null; - try { - const raw = fs.readFileSync(filePath, "utf8"); - const trimmed = raw.trim(); - if (!trimmed) return null; - const MAX = 12_000; - return trimmed.length > MAX ? `${trimmed.slice(0, MAX)}\n\n…(truncated)…\n` : trimmed; - } catch { - return null; - } -} - -// ── Build conflict pack body ──────────────────────────────────────────────── - -export async function buildConflictPackBody( - deps: ConflictPackBuilderDeps, - args: { - laneId: string; - peerLaneId: string | null; - reason: string; - deterministicUpdatedAt: string; - } -): Promise<{ body: string; lastHeadSha: string | null }> { - const laneA = deps.laneService.getLaneBaseAndBranch(args.laneId); - const laneAHead = (await runGitOrThrow(["rev-parse", "HEAD"], { cwd: laneA.worktreePath, timeoutMs: 10_000 })).trim(); - const peerLabel = args.peerLaneId ? `lane:${args.peerLaneId}` : `base:${laneA.baseRef}`; - - const laneBHead = args.peerLaneId - ? (await runGitOrThrow(["rev-parse", "HEAD"], { cwd: deps.laneService.getLaneBaseAndBranch(args.peerLaneId).worktreePath, timeoutMs: 10_000 })).trim() - : (await runGitOrThrow(["rev-parse", laneA.baseRef], { cwd: deps.projectRoot, timeoutMs: 10_000 })).trim(); - - const mergeBase = (await runGitOrThrow(["merge-base", laneAHead, laneBHead], { cwd: deps.projectRoot, timeoutMs: 12_000 })).trim(); - const merge = await runGitMergeTree({ - cwd: deps.projectRoot, - mergeBase, - branchA: laneAHead, - branchB: laneBHead, - timeoutMs: 60_000 - }); - - const touchedA = await runGit(["diff", "--name-only", `${mergeBase}..${laneAHead}`], { cwd: deps.projectRoot, timeoutMs: 20_000 }); - const touchedB = await runGit(["diff", "--name-only", `${mergeBase}..${laneBHead}`], { cwd: deps.projectRoot, timeoutMs: 20_000 }); - const aFiles = new Set(parseDiffNameOnly(touchedA.stdout)); - const bFiles = new Set(parseDiffNameOnly(touchedB.stdout)); - const overlap = uniqueSorted(Array.from(aFiles).filter((file) => bFiles.has(file))); - - const lines: string[] = []; - lines.push(`# Conflict Pack`); - lines.push(""); - lines.push(`- Deterministic updated: ${args.deterministicUpdatedAt}`); - lines.push(`- Trigger: ${args.reason}`); - lines.push(`- Lane: ${args.laneId}`); - lines.push(`- Peer: ${peerLabel}`); - lines.push(`- Merge base: ${mergeBase}`); - lines.push(`- Lane HEAD: ${laneAHead}`); - lines.push(`- Peer HEAD: ${laneBHead}`); - lines.push(""); - - lines.push("## Overlapping Files"); - if (overlap.length) { - for (const file of overlap.slice(0, 120)) { - lines.push(`- ${file}`); - } - if (overlap.length > 120) lines.push(`- … (${overlap.length - 120} more)`); - } else { - lines.push("- none"); - } - lines.push(""); - - lines.push("## Conflicts (merge-tree)"); - if (merge.conflicts.length) { - for (const conflict of merge.conflicts.slice(0, 30)) { - lines.push(`### ${conflict.path} (${conflict.conflictType})`); - if (conflict.markerPreview.trim().length) { - lines.push("```"); - lines.push(conflict.markerPreview.trim()); - lines.push("```"); - } - lines.push(""); - } - if (merge.conflicts.length > 30) { - lines.push(`(truncated) ${merge.conflicts.length} conflicts total.`); - lines.push(""); - } - } else { - lines.push("- no merge-tree conflicts reported"); - lines.push(""); - } - - const lanePackBody = readLanePackExcerpt(deps, args.laneId); - if (lanePackBody) { - lines.push("## Lane Pack (Excerpt)"); - lines.push("```"); - lines.push(lanePackBody.trim()); - lines.push("```"); - lines.push(""); - } - - if (args.peerLaneId) { - const peerPackBody = readLanePackExcerpt(deps, args.peerLaneId); - if (peerPackBody) { - lines.push("## Peer Lane Pack (Excerpt)"); - lines.push("```"); - lines.push(peerPackBody.trim()); - lines.push("```"); - lines.push(""); - } - } - - lines.push("## Narrative"); - lines.push("Conflict packs are data-heavy: overlap lists, merge-tree conflicts, and lane context excerpts."); - lines.push(""); - - return { body: `${lines.join("\n")}\n`, lastHeadSha: laneAHead }; -} diff --git a/apps/desktop/src/main/services/packs/lanePackTemplate.test.ts b/apps/desktop/src/main/services/packs/lanePackTemplate.test.ts deleted file mode 100644 index def97a01..00000000 --- a/apps/desktop/src/main/services/packs/lanePackTemplate.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderLanePackMarkdown } from "./lanePackTemplate"; - -describe("renderLanePackMarkdown", () => { - it("renders the lane pack structure without ANSI, narrative, or AI summaries", () => { - const md = renderLanePackMarkdown({ - packKey: "lane:lane-1", - projectId: "proj-1", - laneId: "lane-1", - laneName: "Test Lane", - branchRef: "feature/test", - baseRef: "main", - headSha: "0123456789abcdef", - dirty: false, - ahead: 1, - behind: 0, - parentName: null, - deterministicUpdatedAt: "2026-02-14T00:00:00.000Z", - trigger: "session_end", - providerMode: "subscription", - whatChangedLines: ["src/api: 2 files (src/api/a.ts, src/api/b.ts)"], - inferredWhyLines: ["abc1234 Add rate limiting"], - userIntentMarkers: { start: "", end: "" }, - userIntent: "Tighten API behavior under load.", - taskSpecMarkers: { start: "", end: "" }, - taskSpec: "- Problem: API degrades under burst.\n- Acceptance: p95 < 200ms\n- Non-goals: rewrite auth", - validationLines: ["Tests: PASS (suite=unit, duration=72ms)"], - keyFiles: [{ file: "src/api/a.ts", insertions: 10, deletions: 2 }], - errors: ["\u001b[31mTypeError: boom\u001b[0m at src/api/a.ts:1"], - sessionsDetailed: [ - { - when: "14:32", - tool: "Shell", - goal: "npm test", - result: "ok", - delta: "+10/-2", - prompt: "Run the unit tests", - commands: ["npm test"], - filesTouched: ["src/api/a.ts"], - errors: [] - } - ], - sessionsTotal: 1, - sessionsRunning: 0, - nextSteps: ["Sync with base"], - userTodosMarkers: { start: "", end: "" }, - userTodos: "- [ ] ship it", - laneDescription: "Implement rate limiting for the public API endpoints" - }); - - expect(md).toContain("```json"); - expect(md).toContain("# Lane: Test Lane"); - expect(md).toContain("## Original Intent"); - expect(md).toContain("Implement rate limiting for the public API endpoints"); - expect(md).toContain("## What Changed"); - expect(md).toContain("## Why"); - expect(md).toContain("## Task Spec"); - expect(md).toContain("## Validation"); - expect(md).toContain("## Key Files"); - expect(md).toContain("## Errors & Issues"); - expect(md).toContain("## Sessions"); - expect(md).toContain("### Session 1:"); - expect(md).toContain("**Prompt**: Run the unit tests"); - expect(md).toContain("**Goal**: npm test"); - expect(md).toContain("**Result**: ok"); - expect(md).toContain("**Delta**: +10/-2"); - expect(md).toContain("**Commands**:"); - expect(md).toContain("**Files touched**:"); - expect(md).toContain("## Open Questions / Next Steps"); - expect(md).not.toContain("\u001b"); - // No narrative section - expect(md).not.toContain("## Narrative"); - expect(md).not.toContain("ADE_NARRATIVE"); - // No AI summaries - expect(md).not.toContain("Recent summaries:"); - // No narrativeUpdatedAt in JSON header - expect(md).not.toContain("narrativeUpdatedAt"); - }); - - it("omits Original Intent section when lane description is empty", () => { - const md = renderLanePackMarkdown({ - packKey: "lane:lane-2", - projectId: "proj-1", - laneId: "lane-2", - laneName: "Empty Lane", - branchRef: "feature/empty", - baseRef: "main", - headSha: "abcdef1234567890", - dirty: true, - ahead: 0, - behind: 0, - parentName: "Main Lane", - deterministicUpdatedAt: "2026-02-14T00:00:00.000Z", - trigger: "manual", - providerMode: "guest", - whatChangedLines: [], - inferredWhyLines: [], - userIntentMarkers: { start: "", end: "" }, - userIntent: "", - taskSpecMarkers: { start: "", end: "" }, - taskSpec: "", - validationLines: [], - keyFiles: [], - errors: [], - sessionsDetailed: [], - sessionsTotal: 0, - sessionsRunning: 0, - nextSteps: [], - userTodosMarkers: { start: "", end: "" }, - userTodos: "", - laneDescription: "" - }); - - expect(md).not.toContain("## Original Intent"); - expect(md).toContain("No sessions recorded yet."); - expect(md).not.toContain("## Narrative"); - }); - - it("shows session count message when total exceeds displayed", () => { - const md = renderLanePackMarkdown({ - packKey: "lane:lane-3", - projectId: "proj-1", - laneId: "lane-3", - laneName: "Busy Lane", - branchRef: "feature/busy", - baseRef: "main", - headSha: "1111111122222222", - dirty: false, - ahead: 5, - behind: 2, - parentName: null, - deterministicUpdatedAt: "2026-02-14T00:00:00.000Z", - trigger: "session_end", - providerMode: "subscription", - whatChangedLines: [], - inferredWhyLines: [], - userIntentMarkers: { start: "", end: "" }, - userIntent: "Test intent", - taskSpecMarkers: { start: "", end: "" }, - taskSpec: "", - validationLines: [], - keyFiles: [], - errors: [], - sessionsDetailed: [ - { when: "10:00", tool: "Claude", goal: "fix bug", result: "ok", delta: "+5/-1", prompt: "Fix the auth bug", commands: [], filesTouched: [], errors: [] } - ], - sessionsTotal: 50, - sessionsRunning: 1, - nextSteps: [], - userTodosMarkers: { start: "", end: "" }, - userTodos: "", - laneDescription: "" - }); - - expect(md).toContain("Showing 1 most recent sessions out of 50 total."); - }); -}); diff --git a/apps/desktop/src/main/services/packs/lanePackTemplate.ts b/apps/desktop/src/main/services/packs/lanePackTemplate.ts deleted file mode 100644 index f099b87f..00000000 --- a/apps/desktop/src/main/services/packs/lanePackTemplate.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { stripAnsi } from "../../utils/ansiStrip"; -import { CONTEXT_CONTRACT_VERSION, CONTEXT_HEADER_SCHEMA_V1 } from "../../../shared/contextContract"; -import type { PackConflictStateV1, PackDependencyStateV1 } from "../../../shared/types"; -import type { PackGraphEnvelopeV1 } from "../../../shared/contextContract"; - -function fmtChange(insertions: number | null, deletions: number | null): string { - if (insertions == null || deletions == null) return "binary"; - return `+${insertions}/-${deletions}`; -} - -function mdCode(value: string): string { - // Inline code cannot contain backticks without escaping; keep it simple. - const clean = value.replace(/`/g, "'"); - return `\`${clean}\``; -} - -export function renderLanePackMarkdown(args: { - packKey: string; - projectId: string | null; - laneId: string; - laneName: string; - branchRef: string; - baseRef: string; - headSha: string | null; - dirty: boolean; - ahead: number; - behind: number; - parentName: string | null; - deterministicUpdatedAt: string; - trigger: string; - providerMode: string; - graph?: PackGraphEnvelopeV1 | null; - dependencyState?: PackDependencyStateV1 | null; - conflictState?: PackConflictStateV1 | null; - whatChangedLines: string[]; - inferredWhyLines: string[]; - userIntentMarkers: { start: string; end: string }; - userIntent: string; - taskSpecMarkers: { start: string; end: string }; - taskSpec: string; - validationLines: string[]; - keyFiles: Array<{ file: string; insertions: number | null; deletions: number | null }>; - errors: string[]; - sessionsDetailed: Array<{ - when: string; - tool: string; - goal: string; - result: string; - delta: string; - prompt: string; - commands: string[]; - filesTouched: string[]; - errors: string[]; - }>; - sessionsTotal: number; - sessionsRunning: number; - nextSteps: string[]; - userTodosMarkers: { start: string; end: string }; - userTodos: string; - laneDescription: string; -}): string { - const shortSha = args.headSha ? args.headSha.slice(0, 8) : "unknown"; - const cleanliness = args.dirty ? "dirty" : "clean"; - - const lines: string[] = []; - lines.push("```json"); - lines.push( - JSON.stringify( - { - schema: CONTEXT_HEADER_SCHEMA_V1, - contractVersion: CONTEXT_CONTRACT_VERSION, - projectId: args.projectId, - packKey: args.packKey, - packType: "lane", - laneId: args.laneId, - peerKey: null, - baseRef: args.baseRef, - headSha: args.headSha, - deterministicUpdatedAt: args.deterministicUpdatedAt, - versionId: null, - versionNumber: null, - contentHash: null, - providerMode: args.providerMode, - graph: args.graph ?? null, - dependencyState: args.dependencyState ?? null, - conflictState: args.conflictState ?? null - }, - null, - 2 - ) - ); - lines.push("```"); - lines.push(""); - lines.push(`# Lane: ${stripAnsi(args.laneName)}`); - lines.push(`> Branch: ${mdCode(stripAnsi(args.branchRef))} | Base: ${mdCode(stripAnsi(args.baseRef))} | HEAD: ${mdCode(shortSha)} | ${cleanliness} · ahead ${args.ahead} · behind ${args.behind}`); - if (args.parentName) lines.push(`> Parent: ${stripAnsi(args.parentName)}`); - lines.push(""); - - const laneDesc = stripAnsi(args.laneDescription).trim(); - if (laneDesc) { - lines.push("## Original Intent"); - lines.push(laneDesc); - lines.push(""); - } - - lines.push("## What Changed"); - if (args.whatChangedLines.length) { - for (const entry of args.whatChangedLines) lines.push(`- ${stripAnsi(entry)}`); - } else { - lines.push("- No changes detected yet."); - } - lines.push(""); - - lines.push("## Why"); - lines.push(args.userIntentMarkers.start); - lines.push(stripAnsi(args.userIntent).trim().length ? stripAnsi(args.userIntent).trim() : "Intent not set — click to add."); - lines.push(args.userIntentMarkers.end); - if (args.inferredWhyLines.length) { - lines.push(""); - lines.push("Inferred from commits:"); - for (const entry of args.inferredWhyLines) lines.push(`- ${stripAnsi(entry)}`); - } - lines.push(""); - - lines.push("## Task Spec"); - lines.push(args.taskSpecMarkers.start); - lines.push(stripAnsi(args.taskSpec).trim().length ? stripAnsi(args.taskSpec).trim() : "- (add task spec here)"); - lines.push(args.taskSpecMarkers.end); - lines.push(""); - - lines.push("## Validation"); - if (args.validationLines.length) { - for (const entry of args.validationLines) lines.push(`- ${stripAnsi(entry)}`); - } else { - lines.push("- Tests: NOT RUN"); - lines.push("- Lint: NOT RUN"); - } - lines.push(""); - - lines.push(`## Key Files (${args.keyFiles.length} files touched)`); - if (!args.keyFiles.length) { - lines.push("No files touched."); - lines.push(""); - } else { - lines.push("| File | Change |"); - lines.push("|------|--------|"); - for (const row of args.keyFiles.slice(0, 25)) { - lines.push(`| ${mdCode(stripAnsi(row.file))} | ${fmtChange(row.insertions, row.deletions)} |`); - } - lines.push(""); - } - - lines.push("## Errors & Issues"); - if (!args.errors.length) { - lines.push("No errors detected."); - } else { - for (const entry of args.errors.slice(0, 30)) lines.push(`- ${stripAnsi(entry)}`); - } - lines.push(""); - - lines.push(`## Sessions (${args.sessionsTotal} total, ${args.sessionsRunning} running)`); - if (args.sessionsDetailed.length) { - for (const [idx, row] of args.sessionsDetailed.slice(0, 30).entries()) { - lines.push(`### Session ${idx + 1}: ${stripAnsi(row.when)} — ${stripAnsi(row.tool)}`); - const prompt = stripAnsi(row.prompt).trim(); - if (prompt) { - lines.push(`- **Prompt**: ${prompt}`); - } - lines.push(`- **Goal**: ${stripAnsi(row.goal)}`); - lines.push(`- **Result**: ${stripAnsi(row.result)}`); - lines.push(`- **Delta**: ${stripAnsi(row.delta)}`); - if (row.commands.length) { - lines.push(`- **Commands**: ${row.commands.map((c) => mdCode(stripAnsi(c))).join(", ")}`); - } - if (row.filesTouched.length) { - lines.push(`- **Files touched**: ${row.filesTouched.map((f) => mdCode(stripAnsi(f))).join(", ")}`); - } - if (row.errors.length) { - lines.push(`- **Errors**: ${row.errors.map((e) => stripAnsi(e)).join("; ")}`); - } - lines.push(""); - } - } else { - lines.push("No sessions recorded yet."); - lines.push(""); - } - if (args.sessionsTotal > args.sessionsDetailed.length) { - lines.push(`Showing ${args.sessionsDetailed.length} most recent sessions out of ${args.sessionsTotal} total.`); - lines.push(""); - } - - lines.push("## Open Questions / Next Steps"); - if (args.nextSteps.length) { - for (const entry of args.nextSteps) lines.push(`- ${stripAnsi(entry)}`); - } else { - lines.push("- (none detected)"); - } - lines.push(""); - lines.push(args.userTodosMarkers.start); - lines.push(stripAnsi(args.userTodos).trim().length ? stripAnsi(args.userTodos).trim() : "- (add notes/todos here)"); - lines.push(args.userTodosMarkers.end); - lines.push(""); - - lines.push("---"); - lines.push( - `*Updated: ${stripAnsi(args.deterministicUpdatedAt)} | Trigger: ${stripAnsi(args.trigger)} | Provider: ${stripAnsi(args.providerMode)} | [View history →](ade://packs/versions/${stripAnsi(args.packKey)})*` - ); - lines.push(""); - - return `${lines.join("\n")}\n`; -} diff --git a/apps/desktop/src/main/services/packs/missionPackBuilder.ts b/apps/desktop/src/main/services/packs/missionPackBuilder.ts deleted file mode 100644 index f8f70b58..00000000 --- a/apps/desktop/src/main/services/packs/missionPackBuilder.ts +++ /dev/null @@ -1,1048 +0,0 @@ -/** - * Mission / Plan / Feature pack builder — generates context packs for - * missions, plans (lane-linked), and feature aggregations. - */ - -import type { AdeDb } from "../state/kvDb"; -import type { Logger } from "../logging/logger"; -import type { createLaneService } from "../lanes/laneService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { - TestRunStatus -} from "../../../shared/types"; -import { - CONTEXT_HEADER_SCHEMA_V1, - CONTEXT_CONTRACT_VERSION, - ADE_INTENT_START, - ADE_INTENT_END, - ADE_TASK_SPEC_START, - ADE_TASK_SPEC_END -} from "../../../shared/contextContract"; -import type { PackRelation } from "../../../shared/contextContract"; -import { runGit } from "../git/git"; -import { - asString, - extractSection, - extractSectionByHeading, - humanToolLabel, - isRecord, - parseRecord, - statusFromCode, - type ConflictPredictionPackFile -} from "./packUtils"; - -// ── Deps ───────────────────────────────────────────────────────────────────── - -export type MissionPackBuilderDeps = { - db: AdeDb; - logger: Logger; - projectRoot: string; - projectId: string; - packsDir: string; - laneService: ReturnType; - projectConfigService: ReturnType; - /** Helpers from the main service that the builder calls back into */ - getLanePackBody: (laneId: string) => Promise; - readConflictPredictionPack: (laneId: string) => ConflictPredictionPackFile | null; - getHeadSha: (worktreePath: string) => Promise; - getPackIndexRow: (packKey: string) => { - pack_type: string; - lane_id: string | null; - pack_path: string; - deterministic_updated_at: string | null; - narrative_updated_at: string | null; - last_head_sha: string | null; - metadata_json: string | null; - } | null; -}; - -// ── Mission Pack ───────────────────────────────────────────────────────────── - -export async function buildMissionPackBody( - deps: MissionPackBuilderDeps, - args: { - missionId: string; - reason: string; - deterministicUpdatedAt: string; - runId?: string | null; - } -): Promise<{ body: string; laneId: string | null }> { - const { db, projectId } = deps; - - const mission = db.get<{ - id: string; - title: string; - prompt: string; - lane_id: string | null; - status: string; - priority: string; - execution_mode: string; - target_machine_id: string | null; - outcome_summary: string | null; - last_error: string | null; - created_at: string; - updated_at: string; - started_at: string | null; - completed_at: string | null; - }>( - ` - select - id, - title, - prompt, - lane_id, - status, - priority, - execution_mode, - target_machine_id, - outcome_summary, - last_error, - created_at, - updated_at, - started_at, - completed_at - from missions - where id = ? - and project_id = ? - limit 1 - `, - [args.missionId, projectId] - ); - if (!mission?.id) throw new Error(`Mission not found: ${args.missionId}`); - - const steps = db.all<{ - id: string; - step_index: number; - title: string; - detail: string | null; - kind: string; - status: string; - lane_id: string | null; - metadata_json: string | null; - started_at: string | null; - completed_at: string | null; - updated_at: string; - }>( - ` - select - id, - step_index, - title, - detail, - kind, - status, - lane_id, - metadata_json, - started_at, - completed_at, - updated_at - from mission_steps - where mission_id = ? - and project_id = ? - order by step_index asc - `, - [args.missionId, projectId] - ); - - const artifactRows = db.all<{ - id: string; - artifact_type: string; - title: string; - description: string | null; - lane_id: string | null; - created_at: string; - }>( - ` - select id, artifact_type, title, description, lane_id, created_at - from mission_artifacts - where mission_id = ? and project_id = ? - order by created_at desc - limit 40 - `, - [args.missionId, projectId] - ); - - const interventionRows = db.all<{ - id: string; - intervention_type: string; - status: string; - title: string; - body: string; - requested_action: string | null; - resolution_note: string | null; - created_at: string; - resolved_at: string | null; - }>( - ` - select id, intervention_type, status, title, body, requested_action, resolution_note, created_at, resolved_at - from mission_interventions - where mission_id = ? and project_id = ? - order by created_at desc - limit 40 - `, - [args.missionId, projectId] - ); - - const handoffs = db.all<{ - handoff_type: string; - producer: string; - created_at: string; - payload_json: string | null; - }>( - ` - select handoff_type, producer, created_at, payload_json - from mission_step_handoffs - where mission_id = ? - and project_id = ? - order by created_at desc - limit 40 - `, - [args.missionId, projectId] - ); - - const runs = db.all<{ - id: string; - status: string; - context_profile: string; - last_error: string | null; - created_at: string; - updated_at: string; - started_at: string | null; - completed_at: string | null; - }>( - ` - select id, status, context_profile, last_error, created_at, updated_at, started_at, completed_at - from orchestrator_runs - where mission_id = ? - and project_id = ? - order by created_at desc - limit 20 - `, - [args.missionId, projectId] - ); - - const lines: string[] = []; - - // JSON header - lines.push("```json"); - lines.push( - JSON.stringify( - { - schema: CONTEXT_HEADER_SCHEMA_V1, - contractVersion: CONTEXT_CONTRACT_VERSION, - projectId, - packType: "mission", - missionId: mission.id, - laneId: mission.lane_id, - status: mission.status, - deterministicUpdatedAt: args.deterministicUpdatedAt, - stepCount: steps.length, - runId: args.runId ?? null - }, - null, - 2 - ) - ); - lines.push("```"); - lines.push(""); - - lines.push(`# Mission Pack: ${mission.title}`); - lines.push(`> Status: ${mission.status} | Priority: ${mission.priority} | Mode: ${mission.execution_mode}`); - lines.push(""); - - lines.push("## Original Prompt"); - lines.push("```"); - lines.push(mission.prompt.trim()); - lines.push("```"); - lines.push(""); - - lines.push("## Mission Metadata"); - lines.push(`- Mission ID: ${mission.id}`); - lines.push(`- Updated: ${args.deterministicUpdatedAt}`); - lines.push(`- Trigger: ${args.reason}`); - lines.push(`- Status: ${mission.status}`); - lines.push(`- Priority: ${mission.priority}`); - lines.push(`- Execution mode: ${mission.execution_mode}`); - if (mission.target_machine_id) lines.push(`- Target machine: ${mission.target_machine_id}`); - if (args.runId) lines.push(`- Orchestrator run: ${args.runId}`); - lines.push(`- Created: ${mission.created_at}`); - lines.push(`- Updated: ${mission.updated_at}`); - if (mission.started_at) lines.push(`- Started: ${mission.started_at}`); - if (mission.completed_at) lines.push(`- Completed: ${mission.completed_at}`); - if (mission.outcome_summary) lines.push(`- Outcome summary: ${mission.outcome_summary}`); - if (mission.last_error) lines.push(`- Last error: ${mission.last_error}`); - lines.push(""); - - // Mission duration - if (mission.started_at) { - const endTime = mission.completed_at ?? args.deterministicUpdatedAt; - lines.push("## Mission Duration"); - lines.push(`- Start: ${mission.started_at}`); - lines.push(`- End: ${mission.completed_at ?? "(in progress)"}`); - const startMs = new Date(mission.started_at).getTime(); - const endMs = new Date(endTime).getTime(); - if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs) { - const durationMin = Math.round((endMs - startMs) / 60_000); - lines.push(`- Duration: ${durationMin}m`); - } - lines.push(""); - } - - // Step Progress - const completedSteps = steps.filter((s) => s.status === "completed").length; - lines.push("## Step Progress"); - lines.push(`Progress: ${completedSteps}/${steps.length} completed`); - lines.push(""); - if (!steps.length) { - lines.push("- No mission steps."); - lines.push(""); - } else { - lines.push("| # | Step | Status | Kind | Lane | Started | Completed |"); - lines.push("|---|------|--------|------|------|---------|-----------|"); - for (const step of steps) { - const detail = step.detail ? ` - ${step.detail.slice(0, 60).replace(/\|/g, "\\|")}` : ""; - lines.push( - `| ${Number(step.step_index) + 1} | ${step.title.replace(/\|/g, "\\|")}${detail} | ${step.status} | ${step.kind} | ${step.lane_id ?? "-"} | ${step.started_at ?? "-"} | ${step.completed_at ?? "-"} |` - ); - } - lines.push(""); - - // Per-step error history from metadata - const stepErrors: string[] = []; - for (const step of steps) { - const meta = parseRecord(step.metadata_json); - if (!meta) continue; - const errors = Array.isArray(meta.errors) ? meta.errors : []; - const lastError = typeof meta.last_error === "string" ? meta.last_error : null; - if (errors.length) { - for (const err of errors.slice(-5)) { - stepErrors.push(`- Step ${Number(step.step_index) + 1} (${step.title}): ${String(err).slice(0, 200)}`); - } - } else if (lastError) { - stepErrors.push(`- Step ${Number(step.step_index) + 1} (${step.title}): ${lastError.slice(0, 200)}`); - } - } - if (stepErrors.length) { - lines.push("### Step Error History"); - for (const se of stepErrors.slice(0, 20)) lines.push(se); - lines.push(""); - } - } - - // Step Timeline - const timelineSteps = steps.filter((s) => s.started_at || s.completed_at); - if (timelineSteps.length) { - lines.push("## Step Timeline"); - const timelineEvents: Array<{ time: string; label: string }> = []; - for (const step of timelineSteps) { - if (step.started_at) { - timelineEvents.push({ time: step.started_at, label: `Step ${Number(step.step_index) + 1} (${step.title}) started` }); - } - if (step.completed_at) { - timelineEvents.push({ time: step.completed_at, label: `Step ${Number(step.step_index) + 1} (${step.title}) completed [${step.status}]` }); - } - } - timelineEvents.sort((a, b) => a.time.localeCompare(b.time)); - for (const ev of timelineEvents) { - lines.push(`- ${ev.time}: ${ev.label}`); - } - lines.push(""); - } - - // Per-step session references - lines.push("## Step Sessions"); - let hasStepSessions = false; - for (const step of steps) { - if (!step.lane_id) continue; - const stepSessions = db.all<{ - id: string; - title: string; - tool_type: string | null; - started_at: string; - ended_at: string | null; - status: string; - exit_code: number | null; - }>( - ` - select id, title, tool_type, started_at, ended_at, status, exit_code - from terminal_sessions - where lane_id = ? - and started_at >= ? - order by started_at asc - limit 8 - `, - [step.lane_id, step.started_at ?? step.updated_at] - ); - if (stepSessions.length) { - hasStepSessions = true; - lines.push(`### Step ${Number(step.step_index) + 1}: ${step.title}`); - for (const sess of stepSessions) { - const tool = humanToolLabel(sess.tool_type); - const outcome = sess.status === "running" ? "RUNNING" : sess.exit_code === 0 ? "OK" : sess.exit_code != null ? `EXIT ${sess.exit_code}` : "ENDED"; - lines.push(`- ${sess.started_at} | ${tool} | ${(sess.title ?? "").slice(0, 60)} | ${outcome}`); - } - lines.push(""); - } - } - if (!hasStepSessions) { - lines.push("- No per-step sessions recorded."); - lines.push(""); - } - - // Artifacts - lines.push("## Artifacts"); - if (!artifactRows.length) { - lines.push("- No artifacts recorded."); - } else { - lines.push(`Total: ${artifactRows.length}`); - lines.push(""); - for (const art of artifactRows) { - const desc = art.description ? ` - ${art.description.slice(0, 100)}` : ""; - lines.push(`- [${art.artifact_type}] ${art.title}${desc} (${art.created_at})`); - } - } - lines.push(""); - - // Interventions - lines.push("## Interventions"); - const openInterventions = interventionRows.filter((i) => i.status === "open").length; - if (!interventionRows.length) { - lines.push("- No interventions recorded."); - } else { - lines.push(`Total: ${interventionRows.length} (${openInterventions} open)`); - lines.push(""); - for (const intv of interventionRows) { - lines.push(`- [${intv.status}] ${intv.intervention_type}: ${intv.title}`); - if (intv.body.trim()) lines.push(` ${intv.body.trim().slice(0, 200)}`); - if (intv.requested_action) lines.push(` Requested: ${intv.requested_action.slice(0, 150)}`); - if (intv.resolution_note) lines.push(` Resolution: ${intv.resolution_note.slice(0, 150)}`); - } - } - lines.push(""); - - // Orchestrator Runs - lines.push("## Orchestrator Runs"); - if (!runs.length) { - lines.push("- No orchestrator runs linked yet."); - } else { - for (const run of runs) { - const duration = run.started_at && run.completed_at - ? `${Math.round((new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 60_000)}m` - : run.started_at ? "in progress" : "-"; - lines.push(`- ${run.id} | ${run.status} | profile=${run.context_profile} | duration=${duration} | updated=${run.updated_at}`); - if (run.last_error) lines.push(` error: ${run.last_error.slice(0, 200)}`); - } - } - lines.push(""); - - // Step Handoffs - lines.push("## Step Handoffs"); - if (!handoffs.length) { - lines.push("- No step handoffs recorded."); - } else { - for (const handoff of handoffs) { - const payload = parseRecord(handoff.payload_json); - const summary = payload?.result && typeof payload.result === "object" - ? String((payload.result as Record).summary ?? "") - : ""; - lines.push( - `- ${handoff.created_at} | ${handoff.handoff_type} | producer=${handoff.producer}${summary ? ` | ${summary}` : ""}` - ); - } - } - lines.push(""); - - if (mission.lane_id) { - const lanePack = await deps.getLanePackBody(mission.lane_id); - if (lanePack.trim().length) { - lines.push("## Lane Context Reference"); - lines.push(`- Lane context key: lane:${mission.lane_id}`); - lines.push("- Lane context source: live lane export compatibility view"); - lines.push(""); - } - } - - lines.push("---"); - lines.push(`*Mission pack: deterministic context snapshot. Updated: ${args.deterministicUpdatedAt}*`); - lines.push(""); - - return { - body: `${lines.join("\n")}\n`, - laneId: mission.lane_id ?? null - }; -} - -// ── Plan Pack ──────────────────────────────────────────────────────────────── - -export async function buildPlanPackBody( - deps: MissionPackBuilderDeps, - args: { - laneId: string; - reason: string; - deterministicUpdatedAt: string; - } -): Promise<{ body: string; headSha: string | null }> { - const { db, projectId } = deps; - const lanes = await deps.laneService.list({ includeArchived: true }); - const lane = lanes.find((l) => l.id === args.laneId); - if (!lane) throw new Error(`Lane not found: ${args.laneId}`); - - const { worktreePath } = deps.laneService.getLaneBaseAndBranch(args.laneId); - const headSha = await deps.getHeadSha(worktreePath); - - const lines: string[] = []; - - // JSON header - lines.push("```json"); - lines.push( - JSON.stringify( - { - schema: CONTEXT_HEADER_SCHEMA_V1, - contractVersion: CONTEXT_CONTRACT_VERSION, - projectId, - packType: "plan", - laneId: args.laneId, - headSha, - deterministicUpdatedAt: args.deterministicUpdatedAt - }, - null, - 2 - ) - ); - lines.push("```"); - lines.push(""); - - // Check if a mission is linked to this lane - const mission = db.get<{ - id: string; - title: string; - prompt: string; - status: string; - priority: string; - created_at: string; - updated_at: string; - started_at: string | null; - completed_at: string | null; - }>( - ` - select id, title, prompt, status, priority, created_at, updated_at, started_at, completed_at - from missions - where lane_id = ? and project_id = ? - order by updated_at desc - limit 1 - `, - [args.laneId, projectId] - ); - - if (mission?.id) { - lines.push(`# Plan: ${mission.title}`); - lines.push(`> Lane: ${lane.name} | Mission: ${mission.id} | Status: ${mission.status} | Priority: ${mission.priority}`); - lines.push(""); - - lines.push("## Original Prompt"); - lines.push("```"); - lines.push(mission.prompt.trim()); - lines.push("```"); - lines.push(""); - - lines.push("## Mission Metadata"); - lines.push(`- Mission ID: ${mission.id}`); - lines.push(`- Status: ${mission.status}`); - lines.push(`- Priority: ${mission.priority}`); - lines.push(`- Created: ${mission.created_at}`); - lines.push(`- Updated: ${mission.updated_at}`); - if (mission.started_at) lines.push(`- Started: ${mission.started_at}`); - if (mission.completed_at) lines.push(`- Completed: ${mission.completed_at}`); - lines.push(""); - - const steps = db.all<{ - id: string; - step_index: number; - title: string; - detail: string | null; - kind: string; - status: string; - lane_id: string | null; - metadata_json: string | null; - started_at: string | null; - completed_at: string | null; - }>( - ` - select id, step_index, title, detail, kind, status, lane_id, metadata_json, started_at, completed_at - from mission_steps - where mission_id = ? and project_id = ? - order by step_index asc - `, - [mission.id, projectId] - ); - - const completedSteps = steps.filter((s) => s.status === "completed").length; - lines.push("## Steps"); - lines.push(`Progress: ${completedSteps}/${steps.length} completed`); - lines.push(""); - - if (steps.length === 0) { - lines.push("- No steps defined yet."); - } else { - lines.push("| # | Step | Status | Kind | Started | Completed |"); - lines.push("|---|------|--------|------|---------|-----------|"); - for (const step of steps) { - const desc = step.detail ? ` - ${step.detail.slice(0, 80).replace(/\|/g, "\\|")}` : ""; - lines.push( - `| ${Number(step.step_index) + 1} | ${step.title.replace(/\|/g, "\\|")}${desc} | ${step.status} | ${step.kind} | ${step.started_at ?? "-"} | ${step.completed_at ?? "-"} |` - ); - } - - const depsLines: string[] = []; - for (const step of steps) { - const meta = parseRecord(step.metadata_json); - const stepDeps = meta && Array.isArray(meta.dependencies) ? meta.dependencies : []; - if (stepDeps.length) { - depsLines.push(`- Step ${Number(step.step_index) + 1} (${step.title}): depends on ${stepDeps.join(", ")}`); - } - } - if (depsLines.length) { - lines.push(""); - lines.push("### Step Dependencies"); - for (const dl of depsLines) lines.push(dl); - } - } - lines.push(""); - - // Timeline - const timelineEntries = steps - .filter((s) => s.started_at || s.completed_at) - .sort((a, b) => (a.started_at ?? a.completed_at ?? "").localeCompare(b.started_at ?? b.completed_at ?? "")); - if (timelineEntries.length) { - lines.push("## Timeline"); - for (const step of timelineEntries) { - const start = step.started_at ?? "-"; - const end = step.completed_at ?? "-"; - lines.push(`- Step ${Number(step.step_index) + 1} (${step.title}): started=${start}, completed=${end}`); - } - lines.push(""); - } - - // Handoff and retry policies from mission metadata - const missionMeta = db.get<{ metadata_json: string | null }>( - "select metadata_json from missions where id = ? and project_id = ?", - [mission.id, projectId] - ); - const missionMetaParsed = parseRecord(missionMeta?.metadata_json); - if (missionMetaParsed) { - const policies: string[] = []; - if (missionMetaParsed.handoffPolicy) policies.push(`- Handoff policy: ${JSON.stringify(missionMetaParsed.handoffPolicy)}`); - if (missionMetaParsed.retryPolicy) policies.push(`- Retry policy: ${JSON.stringify(missionMetaParsed.retryPolicy)}`); - if (policies.length) { - lines.push("## Policies"); - for (const p of policies) lines.push(p); - lines.push(""); - } - } - } else { - // No mission linked: structured template - const lanePackBody = await deps.getLanePackBody(args.laneId); - const intent = extractSection(lanePackBody, ADE_INTENT_START, ADE_INTENT_END, ""); - const taskSpec = extractSection(lanePackBody, ADE_TASK_SPEC_START, ADE_TASK_SPEC_END, ""); - - lines.push(`# Plan: ${lane.name}`); - lines.push(`> Lane: ${lane.name} | Branch: \`${lane.branchRef}\` | No mission linked`); - lines.push(""); - - lines.push("## Objective"); - lines.push(intent.trim().length ? intent.trim() : "Not yet defined"); - lines.push(""); - - lines.push("## Current State"); - const keyFilesMatch = /## Key Files \((\d+) files touched\)/.exec(lanePackBody); - const fileCount = keyFilesMatch ? keyFilesMatch[1] : "0"; - lines.push(`- Files changed: ${fileCount}`); - lines.push(`- Branch status: ${lane.status.dirty ? "dirty" : "clean"}, ahead ${lane.status.ahead}, behind ${lane.status.behind}`); - - const latestTest = db.get<{ status: string; suite_name: string | null; suite_key: string }>( - ` - select r.status as status, s.name as suite_name, r.suite_key as suite_key - from test_runs r - left join test_suites s on s.project_id = r.project_id and s.id = r.suite_key - where r.project_id = ? and r.lane_id = ? - order by r.started_at desc limit 1 - `, - [projectId, args.laneId] - ); - if (latestTest) { - const testLabel = (latestTest.suite_name ?? latestTest.suite_key).trim(); - lines.push(`- Latest test: ${statusFromCode(latestTest.status as TestRunStatus)} (${testLabel})`); - } else { - lines.push("- Latest test: NOT RUN"); - } - lines.push(""); - - lines.push("## Steps"); - lines.push("- (define steps for this lane's work)"); - lines.push(""); - - lines.push("## Dependencies"); - const packKey = `lane:${args.laneId}`; - const packRow = deps.getPackIndexRow(packKey); - if (packRow?.metadata_json) { - const packMeta = parseRecord(packRow.metadata_json); - if (packMeta?.graph && isRecord(packMeta.graph)) { - const graphRelations = Array.isArray((packMeta.graph as Record).relations) - ? ((packMeta.graph as Record).relations as PackRelation[]) - : []; - const blockingRels = graphRelations.filter( - (r) => r.relationType === "blocked_by" || r.relationType === "depends_on" - ); - if (blockingRels.length) { - for (const rel of blockingRels) { - lines.push(`- ${rel.relationType}: ${rel.targetPackKey}`); - } - } else { - lines.push("- No blocking dependencies detected."); - } - } else { - lines.push("- No blocking dependencies detected."); - } - } else { - lines.push("- No blocking dependencies detected."); - } - if (lane.parentLaneId) { - const parentLane = lanes.find((l) => l.id === lane.parentLaneId); - if (parentLane) lines.push(`- Parent lane: ${parentLane.name} (\`${parentLane.branchRef}\`)`); - } - lines.push(""); - - lines.push("## Acceptance Criteria"); - if (taskSpec.trim().length) { - lines.push(taskSpec.trim()); - } else { - lines.push("- (add acceptance criteria here)"); - } - lines.push(""); - } - - lines.push("---"); - lines.push(`*Plan pack: auto-generated for lane ${lane.name}. Updated: ${args.deterministicUpdatedAt}*`); - lines.push(""); - - return { body: `${lines.join("\n")}\n`, headSha }; -} - -// ── Feature Pack ───────────────────────────────────────────────────────────── - -export async function buildFeaturePackBody( - deps: MissionPackBuilderDeps, - args: { - featureKey: string; - reason: string; - deterministicUpdatedAt: string; - } -): Promise<{ body: string; laneIds: string[] }> { - const { db, projectId, projectRoot } = deps; - const lanes = await deps.laneService.list({ includeArchived: false }); - const matching = lanes.filter((lane) => lane.tags.includes(args.featureKey)); - const lanePackBodyCache = new Map(); - const getLanePackBody = async (laneId: string): Promise => { - const cached = lanePackBodyCache.get(laneId); - if (cached != null) return cached; - const body = await deps.getLanePackBody(laneId); - lanePackBodyCache.set(laneId, body); - return body; - }; - const lines: string[] = []; - - // JSON header - lines.push("```json"); - lines.push( - JSON.stringify( - { - schema: CONTEXT_HEADER_SCHEMA_V1, - contractVersion: CONTEXT_CONTRACT_VERSION, - projectId, - packType: "feature", - featureKey: args.featureKey, - deterministicUpdatedAt: args.deterministicUpdatedAt, - laneCount: matching.length, - laneIds: matching.map((l) => l.id) - }, - null, - 2 - ) - ); - lines.push("```"); - lines.push(""); - - lines.push(`# Feature Pack: ${args.featureKey}`); - lines.push(`> Updated: ${args.deterministicUpdatedAt} | Trigger: ${args.reason} | Lanes: ${matching.length}`); - lines.push(""); - - if (matching.length === 0) { - lines.push("No lanes are tagged with this feature key yet."); - lines.push(""); - lines.push("## How To Use"); - lines.push(`- Add the tag '${args.featureKey}' to one or more lanes (Workspace Graph -> right click lane -> Customize).`); - lines.push(""); - return { body: `${lines.join("\n")}\n`, laneIds: [] }; - } - - // Feature Progress Summary - const dirtyCount = matching.filter((l) => l.status.dirty).length; - const cleanCount = matching.length - dirtyCount; - const totalAhead = matching.reduce((sum, l) => sum + l.status.ahead, 0); - const totalBehind = matching.reduce((sum, l) => sum + l.status.behind, 0); - - lines.push("## Feature Progress Summary"); - lines.push(`- Lanes: ${matching.length} (${dirtyCount} dirty, ${cleanCount} clean)`); - lines.push(`- Total ahead: ${totalAhead} | Total behind: ${totalBehind}`); - lines.push(""); - - // Combined File Changes - lines.push("## Combined File Changes"); - type FeatureFileDelta = { insertions: number | null; deletions: number | null }; - const featureDeltas = new Map(); - - for (const lane of matching) { - const { worktreePath } = deps.laneService.getLaneBaseAndBranch(lane.id); - const headSha = await deps.getHeadSha(worktreePath); - const mergeBaseRes = await runGit( - ["merge-base", headSha ?? "HEAD", lane.baseRef?.trim() || "HEAD"], - { cwd: projectRoot, timeoutMs: 12_000 } - ); - const mergeBaseSha = mergeBaseRes.exitCode === 0 ? mergeBaseRes.stdout.trim() : null; - - if (mergeBaseSha && (headSha ?? "HEAD") !== mergeBaseSha) { - const diff = await runGit( - ["diff", "--numstat", `${mergeBaseSha}..${headSha ?? "HEAD"}`], - { cwd: projectRoot, timeoutMs: 20_000 } - ); - if (diff.exitCode === 0) { - for (const diffLine of diff.stdout.split("\n").map((l) => l.trim()).filter(Boolean)) { - const parts = diffLine.split("\t"); - if (parts.length < 3) continue; - const insRaw = parts[0] ?? "0"; - const delRaw = parts[1] ?? "0"; - const filePath = parts.slice(2).join("\t").trim(); - if (!filePath) continue; - const ins = insRaw === "-" ? null : Number(insRaw); - const del = delRaw === "-" ? null : Number(delRaw); - const prev = featureDeltas.get(filePath); - if (!prev) { - featureDeltas.set(filePath, { - insertions: Number.isFinite(ins) ? ins : null, - deletions: Number.isFinite(del) ? del : null - }); - } else { - featureDeltas.set(filePath, { - insertions: prev.insertions == null || ins == null ? null : prev.insertions + ins, - deletions: prev.deletions == null || del == null ? null : prev.deletions + del - }); - } - } - } - } - } - - if (featureDeltas.size === 0) { - lines.push("No file changes detected across feature lanes."); - } else { - const sorted = [...featureDeltas.entries()] - .sort((a, b) => { - const aTotal = (a[1].insertions ?? 0) + (a[1].deletions ?? 0); - const bTotal = (b[1].insertions ?? 0) + (b[1].deletions ?? 0); - return bTotal - aTotal; - }) - .slice(0, 40); - - lines.push("| File | Change |"); - lines.push("|------|--------|"); - for (const [file, delta] of sorted) { - const change = delta.insertions == null || delta.deletions == null ? "binary" : `+${delta.insertions}/-${delta.deletions}`; - lines.push(`| \`${file}\` | ${change} |`); - } - if (featureDeltas.size > 40) { - lines.push(`| ... | ${featureDeltas.size - 40} more files |`); - } - } - lines.push(""); - - // Rolled-up Test Results - lines.push("## Rolled-up Test Results"); - let totalPassed = 0; - let totalFailed = 0; - let totalOtherTests = 0; - const failingTests: string[] = []; - - for (const lane of matching) { - const testRows = db.all<{ - run_id: string; - suite_name: string | null; - suite_key: string; - status: string; - }>( - ` - select - r.id as run_id, - s.name as suite_name, - r.suite_key as suite_key, - r.status as status - from test_runs r - left join test_suites s on s.project_id = r.project_id and s.id = r.suite_key - where r.project_id = ? - and r.lane_id = ? - order by r.started_at desc - limit 3 - `, - [projectId, lane.id] - ); - for (const tr of testRows) { - if (tr.status === "passed") totalPassed++; - else if (tr.status === "failed") { - totalFailed++; - failingTests.push(`${lane.name}: ${(tr.suite_name ?? tr.suite_key).trim()}`); - } else { - totalOtherTests++; - } - } - } - - if (totalPassed + totalFailed + totalOtherTests === 0) { - lines.push("- No test runs recorded across feature lanes."); - } else { - lines.push(`- Passed: ${totalPassed} | Failed: ${totalFailed} | Other: ${totalOtherTests}`); - if (failingTests.length) { - lines.push("- Failing tests:"); - for (const ft of failingTests.slice(0, 20)) { - lines.push(` - ${ft}`); - } - } - } - lines.push(""); - - // Cross-Lane Conflict Predictions - lines.push("## Cross-Lane Conflict Predictions"); - const conflictEntries: string[] = []; - const matchingIds = new Set(matching.map((l) => l.id)); - for (const lane of matching) { - const conflictPack = deps.readConflictPredictionPack(lane.id); - if (!conflictPack) continue; - const overlaps = Array.isArray(conflictPack.overlaps) ? conflictPack.overlaps : []; - for (const ov of overlaps) { - if (!ov || !ov.peerId) continue; - if (!matchingIds.has(ov.peerId)) continue; - const peerName = asString(ov.peerName).trim() || ov.peerId; - const riskLevel = asString(ov.riskLevel).trim() || "unknown"; - const fileCount = Array.isArray(ov.files) ? ov.files.length : 0; - conflictEntries.push(`- ${lane.name} <-> ${peerName}: risk=\`${riskLevel}\`, ${fileCount} overlapping files`); - } - } - if (conflictEntries.length === 0) { - lines.push("- No cross-lane conflict predictions within this feature."); - } else { - for (const entry of conflictEntries.slice(0, 20)) { - lines.push(entry); - } - } - lines.push(""); - - // Combined Session Timeline - lines.push("## Combined Session Timeline"); - const featureSessions = db.all<{ - id: string; - lane_id: string; - title: string; - tool_type: string | null; - started_at: string; - ended_at: string | null; - status: string; - exit_code: number | null; - }>( - ` - select - s.id, s.lane_id, s.title, s.tool_type, s.started_at, s.ended_at, s.status, s.exit_code - from terminal_sessions s - where s.lane_id in (${matching.map(() => "?").join(",")}) - order by s.started_at desc - limit 30 - `, - matching.map((l) => l.id) - ); - - if (featureSessions.length === 0) { - lines.push("- No sessions recorded across feature lanes."); - } else { - lines.push("| When | Lane | Tool | Title | Status |"); - lines.push("|------|------|------|-------|--------|"); - const laneNameById = new Map(matching.map((l) => [l.id, l.name])); - for (const sess of featureSessions) { - const when = sess.started_at.length >= 16 ? sess.started_at.slice(0, 16) : sess.started_at; - const laneName = laneNameById.get(sess.lane_id) ?? sess.lane_id; - const tool = humanToolLabel(sess.tool_type); - const title = (sess.title ?? "").replace(/\|/g, "\\|").slice(0, 60); - const status = sess.status === "running" ? "RUNNING" : sess.exit_code === 0 ? "OK" : sess.exit_code != null ? `EXIT ${sess.exit_code}` : "ENDED"; - lines.push(`| ${when} | ${laneName} | ${tool} | ${title} | ${status} |`); - } - } - lines.push(""); - - // Combined Errors - lines.push("## Combined Errors"); - const allErrors: string[] = []; - for (const lane of matching) { - const lanePackBody = await getLanePackBody(lane.id); - const errSection = extractSectionByHeading(lanePackBody, "## Errors & Issues"); - if (errSection && errSection.trim() !== "No errors detected.") { - for (const errLine of errSection.split("\n").map((l) => l.trim()).filter(Boolean)) { - const cleaned = errLine.startsWith("- ") ? errLine.slice(2) : errLine; - if (cleaned.length) allErrors.push(`[${lane.name}] ${cleaned}`); - } - } - } - if (allErrors.length === 0) { - lines.push("No errors detected across feature lanes."); - } else { - for (const err of allErrors.slice(0, 30)) { - lines.push(`- ${err}`); - } - } - lines.push(""); - - // Per-Lane Details - for (const lane of matching.sort((a, b) => a.stackDepth - b.stackDepth || a.name.localeCompare(b.name))) { - const lanePackBody = await getLanePackBody(lane.id); - const intent = extractSection(lanePackBody, ADE_INTENT_START, ADE_INTENT_END, ""); - - const laneTest = db.get<{ status: string; suite_name: string | null; suite_key: string }>( - ` - select r.status as status, s.name as suite_name, r.suite_key as suite_key - from test_runs r - left join test_suites s on s.project_id = r.project_id and s.id = r.suite_key - where r.project_id = ? and r.lane_id = ? - order by r.started_at desc limit 1 - `, - [projectId, lane.id] - ); - - const keyFilesMatch = /## Key Files \((\d+) files touched\)/.exec(lanePackBody); - const laneFileCount = keyFilesMatch ? Number(keyFilesMatch[1]) : 0; - - lines.push(`### Lane: ${lane.name}`); - lines.push(`- Branch: \`${lane.branchRef}\` | Status: ${lane.status.dirty ? "dirty" : "clean"} | Ahead: ${lane.status.ahead} | Behind: ${lane.status.behind}`); - if (intent.trim().length) { - lines.push(`- Intent: ${intent.trim().slice(0, 200)}`); - } - lines.push(`- Files changed: ${laneFileCount}`); - if (laneTest) { - const testLabel = (laneTest.suite_name ?? laneTest.suite_key).trim(); - lines.push(`- Latest test: ${statusFromCode(laneTest.status as TestRunStatus)} (${testLabel})`); - } else { - lines.push("- Latest test: NOT RUN"); - } - lines.push(""); - } - - lines.push("---"); - lines.push(`*Feature pack: deterministic aggregation across ${matching.length} lanes. Updated: ${args.deterministicUpdatedAt}*`); - lines.push(""); - - return { body: `${lines.join("\n")}\n`, laneIds: matching.map((lane) => lane.id) }; -} diff --git a/apps/desktop/src/main/services/packs/packDeltaDigest.test.ts b/apps/desktop/src/main/services/packs/packDeltaDigest.test.ts deleted file mode 100644 index dd1eca72..00000000 --- a/apps/desktop/src/main/services/packs/packDeltaDigest.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it } from "vitest"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { openKvDb } from "../state/kvDb"; -import { createPackService } from "./packService"; - -function makeLanePackBody(args: { taskSpec: string; intent: string; narrative?: string }): string { - return [ - "# Lane: demo", - "", - "## Task Spec", - "", - args.taskSpec, - "", - "", - "## Why", - "", - args.intent, - "", - "", - "## Narrative", - "", - args.narrative ?? "", - "", - "", - "## Sessions", - "| When | Tool | Goal | Result | Delta |", - "|------|------|------|--------|-------|", - "| 12:00 | Shell | npm test | ok | +1/-0 |", - "" - ].join("\n"); -} - -describe("packService.getDeltaDigest", () => { - it("returns changed sections and injects event meta for legacy payloads", async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-delta-")); - const packsDir = path.join(tmpRoot, "packs"); - fs.mkdirSync(packsDir, { recursive: true }); - - const dbPath = path.join(tmpRoot, "kv.sqlite"); - const logger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; - const db = await openKvDb(dbPath, logger); - - const projectId = "proj-1"; - const now = "2026-02-15T19:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, tmpRoot, "demo", "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, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ["lane-1", projectId, "demo", null, "worktree", "main", "ade/demo", tmpRoot, null, 0, null, null, null, null, "active", now, null] - ); - - const packService = createPackService({ - db, - logger, - projectRoot: tmpRoot, - projectId, - packsDir, - laneService: { list: async () => [], getLaneBaseAndBranch: () => ({ worktreePath: tmpRoot, baseRef: "main", branchRef: "ade/demo" }) } as any, - sessionService: { readTranscriptTail: () => "" } as any, - projectConfigService: { get: () => ({ local: { providers: {} }, effective: { providerMode: "guest", providers: {}, processes: [], testSuites: [], stackButtons: [] } }) } as any, - operationService: { start: () => ({ operationId: "op-1" }), finish: () => {} } as any - }); - - const versionsDir = path.join(packsDir, "versions"); - fs.mkdirSync(versionsDir, { recursive: true }); - - const beforeId = randomUUID(); - const afterId = randomUUID(); - const beforePath = path.join(versionsDir, `${beforeId}.md`); - const afterPath = path.join(versionsDir, `${afterId}.md`); - - const beforeBody = makeLanePackBody({ taskSpec: "old spec", intent: "old intent" }); - const afterBody = makeLanePackBody({ taskSpec: "new spec", intent: "old intent" }); - fs.writeFileSync(beforePath, beforeBody, "utf8"); - fs.writeFileSync(afterPath, afterBody, "utf8"); - - const t0 = "2026-02-15T19:01:00.000Z"; - const t1 = "2026-02-15T19:10:00.000Z"; - db.run( - "insert into pack_versions(id, project_id, pack_key, version_number, content_hash, rendered_path, created_at) values (?, ?, ?, ?, ?, ?, ?)", - [beforeId, projectId, "lane:lane-1", 1, "hash1", beforePath, t0] - ); - db.run( - "insert into pack_versions(id, project_id, pack_key, version_number, content_hash, rendered_path, created_at) values (?, ?, ?, ?, ?, ?, ?)", - [afterId, projectId, "lane:lane-1", 2, "hash2", afterPath, t1] - ); - db.run( - "insert into pack_heads(project_id, pack_key, current_version_id, updated_at) values (?, ?, ?, ?)", - [projectId, "lane:lane-1", afterId, t1] - ); - - // Insert a legacy event without meta keys; getDeltaDigest should still be able to filter/select via injected meta. - const eventId = randomUUID(); - db.run( - "insert into pack_events(id, project_id, pack_key, event_type, payload_json, created_at) values (?, ?, ?, ?, ?, ?)", - [eventId, projectId, "lane:lane-1", "refresh_triggered", JSON.stringify({ trigger: "session_end", laneId: "lane-1" }), "2026-02-15T19:05:00.000Z"] - ); - for (let i = 0; i < 12; i += 1) { - db.run( - "insert into pack_events(id, project_id, pack_key, event_type, payload_json, created_at) values (?, ?, ?, ?, ?, ?)", - [ - randomUUID(), - projectId, - "lane:lane-1", - "refresh_triggered", - JSON.stringify({ trigger: "session_end", laneId: "lane-1", i }), - `2026-02-15T19:${String(6 + i).padStart(2, "0")}:00.000Z` - ] - ); - } - - const digest = await packService.getDeltaDigest({ - packKey: "lane:lane-1", - sinceVersionId: beforeId, - minimumImportance: "medium", - limit: 10 - }); - - expect(digest.newVersion.versionId).toBe(afterId); - expect(digest.changedSections.some((c) => c.sectionId === "task_spec" && c.changeType === "modified")).toBe(true); - expect(digest.highImpactEvents.length).toBeGreaterThan(0); - expect((digest.highImpactEvents[0]!.payload as any).importance).toBeDefined(); - expect((digest.highImpactEvents[0]!.payload as any).category).toBeDefined(); - expect(digest.clipReason).toBe("budget_clipped"); - expect(digest.omittedSections).toContain("events:limit_cap"); - }); -}); diff --git a/apps/desktop/src/main/services/packs/packExports.test.ts b/apps/desktop/src/main/services/packs/packExports.test.ts deleted file mode 100644 index bac1c234..00000000 --- a/apps/desktop/src/main/services/packs/packExports.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ConflictLineageV1, LaneExportManifestV1, PackSummary } from "../../../shared/types"; -import { - ADE_INTENT_END, - ADE_INTENT_START, - ADE_NARRATIVE_END, - ADE_NARRATIVE_START, - ADE_TASK_SPEC_END, - ADE_TASK_SPEC_START, - ADE_TODOS_END, - ADE_TODOS_START -} from "../../../shared/contextContract"; -import type { PackGraphEnvelopeV1 } from "../../../shared/contextContract"; -import { buildConflictExport, buildLaneExport } from "./packExports"; - -function makeLanePackBody(args: { taskSpec: string; intent: string; todos?: string; narrative?: string }): string { - return [ - "# Lane: Test Lane", - "", - "## Why", - ADE_INTENT_START, - args.intent, - ADE_INTENT_END, - "", - "## Task Spec", - ADE_TASK_SPEC_START, - args.taskSpec, - ADE_TASK_SPEC_END, - "", - "## What Changed", - "- src/foo.ts (+10/-2)", - "", - "## Validation", - "- Tests: PASS", - "", - "## Key Files", - "| File | Change |", - "|------|--------|", - "| `src/foo.ts` | +10/-2 |", - "", - "## Errors & Issues", - "- none", - "", - "## Sessions", - "| When | Tool | Goal | Result | Delta |", - "|------|------|------|--------|-------|", - "| 12:00 | Shell | npm test | ok | +10/-2 |", - "", - "## Open Questions / Next Steps", - "- ship it", - "", - "## Notes / Todos", - ADE_TODOS_START, - args.todos ?? "", - ADE_TODOS_END, - "", - "## Narrative", - ADE_NARRATIVE_START, - args.narrative ?? "", - ADE_NARRATIVE_END, - "", - "---", - "*footer*", - "" - ].join("\n"); -} - -function makeLaneManifest(args: { - projectId: string; - laneId: string; - laneName: string; - branchRef: string; - baseRef: string; - headSha: string | null; -}): LaneExportManifestV1 { - return { - schema: "ade.manifest.lane.v1", - projectId: args.projectId, - laneId: args.laneId, - laneName: args.laneName, - laneType: "worktree", - worktreePath: "/tmp/worktree", - branchRef: args.branchRef, - baseRef: args.baseRef, - lineage: { - laneId: args.laneId, - parentLaneId: null, - baseLaneId: args.laneId, - stackDepth: 0 - }, - mergeConstraints: { - requiredMerges: [], - blockedByLanes: [], - mergeReadiness: "unknown" - }, - branchState: { - baseRef: args.baseRef, - headRef: args.branchRef, - headSha: args.headSha, - lastPackRefreshAt: null, - isEditProtected: false, - packStale: null - }, - conflicts: { - activeConflictPackKeys: [], - unresolvedPairCount: 0, - lastConflictRefreshAt: null, - lastConflictRefreshAgeMs: null - } - }; -} - -function parseHeaderFromExport(content: string): any { - const start = content.indexOf("```json"); - if (start < 0) return null; - const end = content.indexOf("```", start + "```json".length); - if (end < 0) return null; - const json = content.slice(start + "```json".length, end).trim(); - return JSON.parse(json); -} - -describe("packExports", () => { - it("buildLaneExport(lite) stays within budget and preserves required markers even with huge user sections", () => { - const projectId = "proj-1"; - const huge = "x".repeat(20_000); - const pack: PackSummary = { - packKey: "lane:lane-1", - packType: "lane", - path: "/tmp/lane_pack.md", - exists: true, - deterministicUpdatedAt: "2026-02-14T00:00:00.000Z", - narrativeUpdatedAt: "2026-02-14T00:00:00.000Z", - lastHeadSha: "0123456789abcdef", - versionId: "ver-1", - versionNumber: 1, - contentHash: "hash", - metadata: null, - body: makeLanePackBody({ - taskSpec: huge, - intent: huge, - todos: huge, - narrative: huge - }) - }; - - const manifest = makeLaneManifest({ - projectId, - laneId: "lane-1", - laneName: "Test Lane", - branchRef: "feature/test", - baseRef: "main", - headSha: pack.lastHeadSha ?? null - }); - const graph: PackGraphEnvelopeV1 = { - schema: "ade.packGraph.v1", - relations: [ - { - relationType: "depends_on", - targetPackKey: "project", - rationale: "test" - } - ] - }; - - const exp = buildLaneExport({ - level: "lite", - projectId, - laneId: "lane-1", - laneName: "Test Lane", - branchRef: "feature/test", - baseRef: "main", - headSha: pack.lastHeadSha, - pack, - providerMode: "guest", - apiBaseUrl: null, - remoteProjectId: null, - graph, - manifest, - markers: { - taskSpecStart: ADE_TASK_SPEC_START, - taskSpecEnd: ADE_TASK_SPEC_END, - intentStart: ADE_INTENT_START, - intentEnd: ADE_INTENT_END, - todosStart: ADE_TODOS_START, - todosEnd: ADE_TODOS_END, - narrativeStart: ADE_NARRATIVE_START, - narrativeEnd: ADE_NARRATIVE_END - }, - conflictRiskSummaryLines: ["- Conflict status: `unknown`"] - }); - - expect(exp.approxTokens).toBeLessThanOrEqual(exp.maxTokens); - expect(exp.content).toContain("```json"); - expect(exp.content).toContain("## Task Spec"); - expect(exp.content).toContain(ADE_TASK_SPEC_START); - expect(exp.content).toContain(ADE_TASK_SPEC_END); - expect(exp.content).toContain("## Intent"); - expect(exp.content).toContain(ADE_INTENT_START); - expect(exp.content).toContain(ADE_INTENT_END); - expect(exp.content).toContain("## Conflict Risk Summary"); - expect(exp.content).toContain("...(truncated)..."); - expect(exp.content).toContain("## Manifest"); - expect(exp.clipReason).toBe("budget_clipped"); - expect((exp.omittedSections ?? []).length).toBeGreaterThan(0); - const header = parseHeaderFromExport(exp.content); - expect(header.projectId).toBe(projectId); - expect(header.graph?.schema).toBe("ade.packGraph.v1"); - }); - - it("buildLaneExport(deep) can include narrative markers, while standard omits narrative by default", () => { - const projectId = "proj-1"; - const pack: PackSummary = { - packKey: "lane:lane-2", - packType: "lane", - path: "/tmp/lane_pack.md", - exists: true, - deterministicUpdatedAt: "2026-02-14T00:00:00.000Z", - narrativeUpdatedAt: "2026-02-14T00:00:00.000Z", - lastHeadSha: "abcdef0123456789", - body: makeLanePackBody({ - taskSpec: "- do the thing", - intent: "because reasons", - narrative: "narrative text" - }) - }; - - const commonArgs: Omit[0], "level"> = { - projectId, - laneId: "lane-2", - laneName: "Test Lane 2", - branchRef: "feature/test2", - baseRef: "main", - headSha: pack.lastHeadSha, - pack, - providerMode: "guest", - apiBaseUrl: null, - remoteProjectId: null, - manifest: makeLaneManifest({ - projectId, - laneId: "lane-2", - laneName: "Test Lane 2", - branchRef: "feature/test2", - baseRef: "main", - headSha: pack.lastHeadSha ?? null - }), - markers: { - taskSpecStart: ADE_TASK_SPEC_START, - taskSpecEnd: ADE_TASK_SPEC_END, - intentStart: ADE_INTENT_START, - intentEnd: ADE_INTENT_END, - todosStart: ADE_TODOS_START, - todosEnd: ADE_TODOS_END, - narrativeStart: ADE_NARRATIVE_START, - narrativeEnd: ADE_NARRATIVE_END - }, - conflictRiskSummaryLines: [] as string[] - }; - - const standard = buildLaneExport({ - ...commonArgs, - level: "standard" - }); - - expect(standard.approxTokens).toBeLessThanOrEqual(standard.maxTokens); - expect(standard.content).not.toContain("## Narrative (Deep)"); - expect(standard.content).not.toContain(ADE_NARRATIVE_START); - - const deep = buildLaneExport({ - ...commonArgs, - level: "deep" - }); - - expect(deep.approxTokens).toBeLessThanOrEqual(deep.maxTokens); - expect(deep.content).toContain("## Narrative (Deep)"); - expect(deep.content).toContain(ADE_NARRATIVE_START); - expect(deep.content).toContain(ADE_NARRATIVE_END); - }); - - it("buildConflictExport includes conflict lineage JSON when provided", () => { - const projectId = "proj-1"; - const pack: PackSummary = { - packKey: "conflict:lane-1:main", - packType: "conflict", - path: "/tmp/conflict_pack.md", - exists: true, - deterministicUpdatedAt: "2026-02-14T00:00:00.000Z", - narrativeUpdatedAt: null, - lastHeadSha: "abcdef0123456789", - body: "# Conflict Pack\n\n## Overlapping Files\n- a\n" - }; - - const lineage: ConflictLineageV1 = { - schema: "ade.conflictLineage.v1", - laneId: "lane-1", - peerKey: "main", - predictionAt: null, - lastRecomputedAt: null, - truncated: null, - strategy: null, - pairwisePairsComputed: null, - pairwisePairsTotal: null, - stalePolicy: { ttlMs: 300000 }, - openConflictSummaries: [], - unresolvedResolutionState: null - }; - - const exp = buildConflictExport({ - level: "standard", - projectId, - packKey: pack.packKey, - laneId: "lane-1", - peerLabel: "base:main", - pack, - providerMode: "guest", - apiBaseUrl: null, - remoteProjectId: null, - lineage - }); - - expect(exp.content).toContain("## Conflict Lineage"); - expect(exp.content).toContain("\"schema\": \"ade.conflictLineage.v1\""); - const header = parseHeaderFromExport(exp.content); - expect(header.projectId).toBe(projectId); - }); -}); diff --git a/apps/desktop/src/main/services/packs/packExports.ts b/apps/desktop/src/main/services/packs/packExports.ts deleted file mode 100644 index 544cd686..00000000 --- a/apps/desktop/src/main/services/packs/packExports.ts +++ /dev/null @@ -1,665 +0,0 @@ -import type { - ConflictLineageV1, - ContextExportLevel, - ContextHeaderV1, - LaneExportManifestV1, - PackConflictStateV1, - PackDependencyStateV1, - PackExport, - PackSummary, - PackType, - ProjectExportManifestV1, - ProviderMode -} from "../../../shared/types"; -import { CONTEXT_CONTRACT_VERSION, CONTEXT_HEADER_SCHEMA_V1 } from "../../../shared/contextContract"; -import type { ExportOmissionV1, PackGraphEnvelopeV1 } from "../../../shared/contextContract"; -import { stripAnsi } from "../../utils/ansiStrip"; -import { extractBetweenMarkers, renderJsonSection } from "./packSections"; - -type Budget = { maxTokens: number }; - -const DEFAULT_BUDGETS: Record> = { - project: { - lite: { maxTokens: 900 }, - standard: { maxTokens: 2500 }, - deep: { maxTokens: 6500 } - }, - lane: { - lite: { maxTokens: 800 }, - standard: { maxTokens: 2800 }, - deep: { maxTokens: 8000 } - }, - conflict: { - lite: { maxTokens: 1100 }, - standard: { maxTokens: 3200 }, - deep: { maxTokens: 9000 } - }, - feature: { - lite: { maxTokens: 1000 }, - standard: { maxTokens: 2800 }, - deep: { maxTokens: 8000 } - }, - plan: { - lite: { maxTokens: 1100 }, - standard: { maxTokens: 3200 }, - deep: { maxTokens: 9000 } - }, - mission: { - lite: { maxTokens: 1200 }, - standard: { maxTokens: 3600 }, - deep: { maxTokens: 9000 } - } -}; - -function approxTokensFromText(text: string): number { - // Lightweight heuristic: ~4 characters/token for English+code. - return Math.max(0, Math.ceil((text ?? "").length / 4)); -} - -function normalizeForExport(text: string): string { - // Keep exports stable, human-readable, and safe to send over the wire. - return stripAnsi(String(text ?? "")).replace(/\r\n/g, "\n"); -} - -function renderHeaderFence(header: ContextHeaderV1, opts: { pretty?: boolean } = {}): string { - const pretty = opts.pretty !== false; - return ["```json", pretty ? JSON.stringify(header, null, 2) : JSON.stringify(header), "```", ""].join("\n"); -} - -function ensureBudgetOmission(omissions: ExportOmissionV1[], truncated: boolean): ExportOmissionV1[] { - if (!truncated) return omissions; - if (omissions.some((o) => o.sectionId === "export" && o.reason === "budget_clipped")) return omissions; - return [ - ...omissions, - { - sectionId: "export", - reason: "budget_clipped", - detail: "Export clipped to fit token budget.", - recommendedLevel: "deep" - } - ]; -} - -function takeLines(lines: string[], max: number): { lines: string[]; truncated: boolean } { - if (lines.length <= max) return { lines, truncated: false }; - return { lines: lines.slice(0, Math.max(0, max)), truncated: true }; -} - -function clipBlock(text: string, maxChars: number): { text: string; truncated: boolean } { - const normalized = normalizeForExport(text ?? "").trim(); - if (maxChars <= 0) return { text: normalized, truncated: false }; - if (normalized.length <= maxChars) return { text: normalized, truncated: false }; - const clipped = `${normalized.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n...(truncated)...\n`; - return { text: clipped, truncated: true }; -} - -function extractSectionLines(args: { - content: string; - headingPrefix: string; - maxLines: number; -}): { lines: string[]; truncated: boolean } { - const raw = normalizeForExport(args.content); - const lines = raw.split("\n"); - - const startIdx = lines.findIndex((line) => line.trim() === args.headingPrefix || line.startsWith(args.headingPrefix)); - if (startIdx < 0) return { lines: [], truncated: false }; - - const out: string[] = []; - let inCodeFence = false; - for (let i = startIdx + 1; i < lines.length; i++) { - const line = lines[i] ?? ""; - const trimmed = line.trim(); - if (trimmed.startsWith("```")) inCodeFence = !inCodeFence; - if (!inCodeFence && trimmed.startsWith("## ")) break; - if (!inCodeFence && trimmed === "---") break; - out.push(line); - } - - // Drop leading/trailing whitespace-only lines to keep exports tidy. - while (out.length && !out[0]!.trim()) out.shift(); - while (out.length && !out[out.length - 1]!.trim()) out.pop(); - - return takeLines(out, args.maxLines); -} - -function clipToBudget(args: { content: string; maxTokens: number }): { content: string; truncated: boolean } { - const normalized = normalizeForExport(args.content); - const approx = approxTokensFromText(normalized); - if (approx <= args.maxTokens) return { content: normalized, truncated: false }; - - const maxChars = Math.max(0, args.maxTokens * 4); - if (normalized.length <= maxChars) return { content: normalized, truncated: false }; - const clipped = `${normalized.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n\n...(truncated)...\n`; - return { content: clipped, truncated: true }; -} - -export function buildLaneExport(args: { - level: ContextExportLevel; - projectId: string | null; - laneId: string; - laneName: string; - branchRef: string; - baseRef: string; - headSha: string | null; - pack: PackSummary; - providerMode: ProviderMode; - apiBaseUrl: string | null; - remoteProjectId: string | null; - graph?: PackGraphEnvelopeV1 | null; - manifest?: LaneExportManifestV1 | null; - dependencyState?: PackDependencyStateV1 | null; - conflictState?: PackConflictStateV1 | null; - markers: { - taskSpecStart: string; - taskSpecEnd: string; - intentStart: string; - intentEnd: string; - todosStart: string; - todosEnd: string; - narrativeStart: string; - narrativeEnd: string; - }; - conflictRiskSummaryLines: string[]; -}): PackExport { - const level = args.level; - const budget = DEFAULT_BUDGETS.lane[level]; - - const body = normalizeForExport(args.pack.body ?? ""); - - const taskSpecRaw = - extractBetweenMarkers(body, args.markers.taskSpecStart, args.markers.taskSpecEnd) ?? - "(task spec missing; lane context markers unavailable)"; - const intentRaw = - extractBetweenMarkers(body, args.markers.intentStart, args.markers.intentEnd) ?? - "(intent missing; lane context markers unavailable)"; - const todosRaw = extractBetweenMarkers(body, args.markers.todosStart, args.markers.todosEnd) ?? ""; - const narrativeRaw = extractBetweenMarkers(body, args.markers.narrativeStart, args.markers.narrativeEnd) ?? ""; - - const whatChanged = extractSectionLines({ - content: body, - headingPrefix: "## What Changed", - maxLines: level === "lite" ? 10 : level === "standard" ? 24 : 80 - }); - const validation = extractSectionLines({ - content: body, - headingPrefix: "## Validation", - maxLines: level === "lite" ? 8 : level === "standard" ? 16 : 40 - }); - const keyFiles = extractSectionLines({ - content: body, - headingPrefix: "## Key Files", - maxLines: level === "lite" ? 10 : level === "standard" ? 20 : 60 - }); - const errors = extractSectionLines({ - content: body, - headingPrefix: "## Errors & Issues", - maxLines: level === "lite" ? 12 : level === "standard" ? 30 : 120 - }); - const sessions = extractSectionLines({ - content: body, - headingPrefix: "## Sessions", - maxLines: level === "lite" ? 12 : level === "standard" ? 24 : 80 - }); - const nextSteps = extractSectionLines({ - content: body, - headingPrefix: "## Open Questions / Next Steps", - maxLines: level === "lite" ? 16 : level === "standard" ? 40 : 120 - }); - - const warnings: string[] = []; - const omissionsBase: ExportOmissionV1[] = []; - const userBlockLimits = - level === "lite" - ? { taskSpecChars: 650, intentChars: 360, todosChars: 450, narrativeChars: 0 } - : level === "standard" - ? { taskSpecChars: 2200, intentChars: 1400, todosChars: 1200, narrativeChars: 0 } - : { taskSpecChars: 4000, intentChars: 2200, todosChars: 2000, narrativeChars: 5000 }; - - const taskSpec = clipBlock(taskSpecRaw, userBlockLimits.taskSpecChars); - const intent = clipBlock(intentRaw, userBlockLimits.intentChars); - const todos = clipBlock(todosRaw, userBlockLimits.todosChars); - const narrative = clipBlock(narrativeRaw, userBlockLimits.narrativeChars); - - if (taskSpec.truncated) { - warnings.push("Task Spec section truncated for export budget."); - omissionsBase.push({ sectionId: "task_spec", reason: "truncated_section", detail: "Task Spec truncated." }); - } - if (intent.truncated) { - warnings.push("Intent section truncated for export budget."); - omissionsBase.push({ sectionId: "intent", reason: "truncated_section", detail: "Intent truncated." }); - } - if (todos.truncated) { - warnings.push("Todos section truncated for export budget."); - omissionsBase.push({ sectionId: "todos", reason: "truncated_section", detail: "Todos truncated." }); - } - if (narrative.truncated) { - warnings.push("Narrative section truncated for export budget."); - omissionsBase.push({ sectionId: "narrative", reason: "truncated_section", detail: "Narrative truncated." }); - } - if (whatChanged.truncated) { - warnings.push("What Changed section truncated for export budget."); - omissionsBase.push({ sectionId: "what_changed", reason: "truncated_section", detail: "What Changed truncated." }); - } - if (validation.truncated) { - warnings.push("Validation section truncated for export budget."); - omissionsBase.push({ sectionId: "validation", reason: "truncated_section", detail: "Validation truncated." }); - } - if (keyFiles.truncated) { - warnings.push("Key Files section truncated for export budget."); - omissionsBase.push({ sectionId: "key_files", reason: "truncated_section", detail: "Key Files truncated." }); - } - if (errors.truncated) { - warnings.push("Errors section truncated for export budget."); - omissionsBase.push({ sectionId: "errors", reason: "truncated_section", detail: "Errors truncated." }); - } - if (sessions.truncated) { - warnings.push("Sessions section truncated for export budget."); - omissionsBase.push({ sectionId: "sessions", reason: "truncated_section", detail: "Sessions truncated." }); - } - if (nextSteps.truncated) { - warnings.push("Next Steps section truncated for export budget."); - omissionsBase.push({ sectionId: "next_steps", reason: "truncated_section", detail: "Next Steps truncated." }); - } - - const exportedAt = new Date().toISOString(); - const header: ContextHeaderV1 = { - schema: CONTEXT_HEADER_SCHEMA_V1, - contractVersion: CONTEXT_CONTRACT_VERSION, - projectId: args.projectId, - packKey: args.pack.packKey, - packType: "lane", - exportLevel: level, - laneId: args.laneId, - peerKey: null, - baseRef: args.baseRef, - headSha: args.headSha, - deterministicUpdatedAt: args.pack.deterministicUpdatedAt, - narrativeUpdatedAt: args.pack.narrativeUpdatedAt, - versionId: args.pack.versionId ?? null, - versionNumber: args.pack.versionNumber ?? null, - contentHash: args.pack.contentHash ?? null, - providerMode: args.providerMode, - exportedAt, - apiBaseUrl: args.apiBaseUrl, - remoteProjectId: args.remoteProjectId, - graph: args.graph ?? null, - dependencyState: args.dependencyState ?? null, - conflictState: args.conflictState ?? null, - omissions: null - }; - - const lines: string[] = []; - lines.push(`# Lane Export (${level.toUpperCase()})`); - lines.push( - `> Lane: ${normalizeForExport(args.laneName)} | Branch: \`${normalizeForExport(args.branchRef)}\` | Base: \`${normalizeForExport(args.baseRef)}\`` - ); - lines.push(""); - - if (args.manifest) { - const liteManifest = - level === "lite" - ? { - schema: args.manifest.schema, - projectId: args.manifest.projectId, - laneId: args.manifest.laneId, - laneName: args.manifest.laneName, - laneType: args.manifest.laneType, - branchRef: args.manifest.branchRef, - baseRef: args.manifest.baseRef, - lineage: args.manifest.lineage, - mergeConstraints: args.manifest.mergeConstraints, - branchState: { - baseRef: args.manifest.branchState?.baseRef ?? null, - headRef: args.manifest.branchState?.headRef ?? null, - headSha: args.manifest.branchState?.headSha ?? null, - lastPackRefreshAt: args.manifest.branchState?.lastPackRefreshAt ?? null, - isEditProtected: args.manifest.branchState?.isEditProtected ?? null, - packStale: args.manifest.branchState?.packStale ?? null, - ...(args.manifest.branchState?.packStaleReason ? { packStaleReason: args.manifest.branchState.packStaleReason } : {}) - }, - conflicts: { - activeConflictPackKeys: args.manifest.conflicts?.activeConflictPackKeys ?? [], - unresolvedPairCount: args.manifest.conflicts?.unresolvedPairCount ?? 0, - lastConflictRefreshAt: args.manifest.conflicts?.lastConflictRefreshAt ?? null, - lastConflictRefreshAgeMs: args.manifest.conflicts?.lastConflictRefreshAgeMs ?? null, - ...(args.manifest.conflicts?.predictionStale != null ? { predictionStale: args.manifest.conflicts.predictionStale } : {}), - ...(args.manifest.conflicts?.stalePolicy ? { stalePolicy: args.manifest.conflicts.stalePolicy } : {}), - ...(args.manifest.conflicts?.staleReason ? { staleReason: args.manifest.conflicts.staleReason } : {}) - } - } - : args.manifest; - - lines.push(...renderJsonSection("## Manifest", liteManifest, { pretty: level !== "lite" })); - } else { - lines.push(...renderJsonSection("## Manifest", { schema: "ade.manifest.lane.v1", unavailable: true }, { pretty: level !== "lite" })); - omissionsBase.push({ sectionId: "manifest", reason: "data_unavailable", detail: "Manifest unavailable." }); - } - - lines.push("## Task Spec"); - lines.push(args.markers.taskSpecStart); - lines.push(taskSpec.text); - lines.push(args.markers.taskSpecEnd); - lines.push(""); - - lines.push("## Intent"); - lines.push(args.markers.intentStart); - lines.push(intent.text); - lines.push(args.markers.intentEnd); - lines.push(""); - - lines.push("## Conflict Risk Summary"); - if (args.conflictRiskSummaryLines.length) { - const max = level === "lite" ? 8 : args.conflictRiskSummaryLines.length; - for (const line of args.conflictRiskSummaryLines.slice(0, max)) lines.push(line); - } else { - lines.push("- Conflict status: unknown (prediction not available yet)"); - } - lines.push(""); - - if (whatChanged.lines.length) { - lines.push("## What Changed"); - lines.push(...whatChanged.lines); - lines.push(""); - } - - if (validation.lines.length) { - lines.push("## Validation"); - lines.push(...validation.lines); - lines.push(""); - } - - if (keyFiles.lines.length) { - lines.push("## Key Files"); - lines.push(...keyFiles.lines); - lines.push(""); - } - - if (errors.lines.length) { - lines.push("## Errors & Issues"); - lines.push(...errors.lines); - lines.push(""); - } - - if (sessions.lines.length) { - lines.push("## Sessions"); - lines.push(...sessions.lines); - lines.push(""); - } - - if (nextSteps.lines.length) { - lines.push("## Next Steps"); - lines.push(...nextSteps.lines); - lines.push(""); - } - - if (todos.text.trim().length) { - lines.push("## Notes / Todos"); - lines.push(args.markers.todosStart); - lines.push(todos.text); - lines.push(args.markers.todosEnd); - lines.push(""); - } - - if (level === "deep" && narrative.text.trim().length) { - lines.push("## Narrative (Deep)"); - lines.push(args.markers.narrativeStart); - lines.push(narrative.text); - lines.push(args.markers.narrativeEnd); - lines.push(""); - } else if (level !== "deep") { - omissionsBase.push({ - sectionId: "narrative", - reason: "omitted_by_level", - detail: "Narrative is only included at deep export level.", - recommendedLevel: "deep" - }); - } - - const buildContent = (omissions: ExportOmissionV1[]) => { - header.omissions = omissions.length ? omissions : null; - header.maxTokens = budget.maxTokens; - const draft = `${renderHeaderFence(header, { pretty: level !== "lite" })}${lines.join("\n")}\n`; - return clipToBudget({ content: draft, maxTokens: budget.maxTokens }); - }; - - let clipped = buildContent(omissionsBase); - const omissionsFinal = ensureBudgetOmission(omissionsBase, clipped.truncated); - if (omissionsFinal !== omissionsBase) { - clipped = buildContent(omissionsFinal); - } - - const approxTokens = approxTokensFromText(clipped.content); - header.approxTokens = approxTokens; - - return { - packKey: args.pack.packKey, - packType: "lane", - level, - header, - content: clipped.content, - approxTokens, - maxTokens: budget.maxTokens, - truncated: clipped.truncated, - warnings: clipped.truncated ? [...warnings, "Export clipped to fit token budget."] : warnings, - clipReason: clipped.truncated ? "budget_clipped" : null, - omittedSections: (header.omissions ?? []).map((entry) => entry.sectionId) - }; -} - -export function buildProjectExport(args: { - level: ContextExportLevel; - projectId: string | null; - pack: PackSummary; - providerMode: ProviderMode; - apiBaseUrl: string | null; - remoteProjectId: string | null; - graph?: PackGraphEnvelopeV1 | null; - manifest?: ProjectExportManifestV1 | null; -}): PackExport { - const level = args.level; - const budget = DEFAULT_BUDGETS.project[level]; - const body = normalizeForExport(args.pack.body ?? ""); - - const overview = extractSectionLines({ - content: body, - headingPrefix: "# Project Pack", - maxLines: level === "lite" ? 60 : level === "standard" ? 140 : 400 - }); - - const warnings: string[] = []; - const omissionsBase: ExportOmissionV1[] = []; - - const exportedAt = new Date().toISOString(); - const header: ContextHeaderV1 = { - schema: CONTEXT_HEADER_SCHEMA_V1, - contractVersion: CONTEXT_CONTRACT_VERSION, - projectId: args.projectId, - packKey: args.pack.packKey, - packType: "project", - exportLevel: level, - laneId: null, - peerKey: null, - baseRef: null, - headSha: null, - deterministicUpdatedAt: args.pack.deterministicUpdatedAt, - narrativeUpdatedAt: args.pack.narrativeUpdatedAt, - versionId: args.pack.versionId ?? null, - versionNumber: args.pack.versionNumber ?? null, - contentHash: args.pack.contentHash ?? null, - providerMode: args.providerMode, - exportedAt, - apiBaseUrl: args.apiBaseUrl, - remoteProjectId: args.remoteProjectId, - graph: args.graph ?? null, - omissions: null - }; - - const lines: string[] = []; - lines.push(`# Project Export (${level.toUpperCase()})`); - lines.push(""); - - if (args.manifest) { - lines.push(...renderJsonSection("## Manifest", args.manifest, { pretty: level !== "lite" })); - } else { - lines.push(...renderJsonSection("## Manifest", { schema: "ade.manifest.project.v1", unavailable: true }, { pretty: level !== "lite" })); - omissionsBase.push({ sectionId: "manifest", reason: "data_unavailable", detail: "Manifest unavailable." }); - } - - if (overview.lines.length) { - lines.push("## Snapshot"); - lines.push(...overview.lines); - lines.push(""); - } else { - lines.push("## Snapshot"); - lines.push("- Project context is currently unavailable."); - lines.push(""); - omissionsBase.push({ sectionId: "snapshot", reason: "data_unavailable", detail: "Snapshot unavailable." }); - } - - if (overview.truncated) { - warnings.push("Project snapshot truncated for export budget."); - omissionsBase.push({ sectionId: "snapshot", reason: "truncated_section", detail: "Snapshot truncated." }); - } - - const buildContent = (omissions: ExportOmissionV1[]) => { - header.omissions = omissions.length ? omissions : null; - header.maxTokens = budget.maxTokens; - const draft = `${renderHeaderFence(header, { pretty: level !== "lite" })}${lines.join("\n")}\n`; - return clipToBudget({ content: draft, maxTokens: budget.maxTokens }); - }; - - let clipped = buildContent(omissionsBase); - const omissionsFinal = ensureBudgetOmission(omissionsBase, clipped.truncated); - if (omissionsFinal !== omissionsBase) { - clipped = buildContent(omissionsFinal); - } - - const approxTokens = approxTokensFromText(clipped.content); - header.approxTokens = approxTokens; - - return { - packKey: args.pack.packKey, - packType: "project", - level, - header, - content: clipped.content, - approxTokens, - maxTokens: budget.maxTokens, - truncated: clipped.truncated, - warnings: clipped.truncated ? [...warnings, "Export clipped to fit token budget."] : warnings, - clipReason: clipped.truncated ? "budget_clipped" : null, - omittedSections: (header.omissions ?? []).map((entry) => entry.sectionId) - }; -} - -export function buildConflictExport(args: { - level: ContextExportLevel; - projectId: string | null; - packKey: string; - laneId: string; - peerLabel: string; - pack: PackSummary; - providerMode: ProviderMode; - apiBaseUrl: string | null; - remoteProjectId: string | null; - graph?: PackGraphEnvelopeV1 | null; - lineage?: ConflictLineageV1 | null; -}): PackExport { - const level = args.level; - const budget = DEFAULT_BUDGETS.conflict[level]; - const body = normalizeForExport(args.pack.body ?? ""); - - const overlapLines = extractSectionLines({ - content: body, - headingPrefix: "## Overlapping Files", - maxLines: level === "lite" ? 24 : level === "standard" ? 60 : 220 - }); - const conflictsLines = extractSectionLines({ - content: body, - headingPrefix: "## Conflicts (merge-tree)", - maxLines: level === "lite" ? 60 : level === "standard" ? 140 : 400 - }); - - const warnings: string[] = []; - const omissionsBase: ExportOmissionV1[] = []; - if (overlapLines.truncated) warnings.push("Overlap list truncated for export budget."); - if (conflictsLines.truncated) warnings.push("Conflicts section truncated for export budget."); - if (overlapLines.truncated) omissionsBase.push({ sectionId: "overlap_files", reason: "truncated_section", detail: "Overlap list truncated." }); - if (conflictsLines.truncated) omissionsBase.push({ sectionId: "merge_tree", reason: "truncated_section", detail: "Merge-tree conflicts truncated." }); - - const exportedAt = new Date().toISOString(); - const header: ContextHeaderV1 = { - schema: CONTEXT_HEADER_SCHEMA_V1, - contractVersion: CONTEXT_CONTRACT_VERSION, - projectId: args.projectId, - packKey: args.packKey, - packType: "conflict", - exportLevel: level, - laneId: args.laneId, - peerKey: args.peerLabel, - baseRef: null, - headSha: args.pack.lastHeadSha ?? null, - deterministicUpdatedAt: args.pack.deterministicUpdatedAt, - narrativeUpdatedAt: args.pack.narrativeUpdatedAt, - versionId: args.pack.versionId ?? null, - versionNumber: args.pack.versionNumber ?? null, - contentHash: args.pack.contentHash ?? null, - providerMode: args.providerMode, - exportedAt, - apiBaseUrl: args.apiBaseUrl, - remoteProjectId: args.remoteProjectId, - graph: args.graph ?? null, - omissions: null - }; - - const lines: string[] = []; - lines.push(`# Conflict Export (${level.toUpperCase()})`); - lines.push(`> Lane: ${args.laneId} | Peer: ${normalizeForExport(args.peerLabel)}`); - lines.push(""); - - if (args.lineage) { - lines.push(...renderJsonSection("## Conflict Lineage", args.lineage, { pretty: level !== "lite" })); - } else { - omissionsBase.push({ sectionId: "conflict_lineage", reason: "data_unavailable", detail: "Conflict lineage unavailable." }); - } - - lines.push("## Overlapping Files"); - if (overlapLines.lines.length) lines.push(...overlapLines.lines); - else lines.push("- (none listed; live conflict context is unavailable)"); - lines.push(""); - - lines.push("## Conflicts (merge-tree)"); - if (conflictsLines.lines.length) lines.push(...conflictsLines.lines); - else lines.push("- (none listed; live conflict context is unavailable)"); - lines.push(""); - - const buildContent = (omissions: ExportOmissionV1[]) => { - header.omissions = omissions.length ? omissions : null; - header.maxTokens = budget.maxTokens; - const draft = `${renderHeaderFence(header, { pretty: level !== "lite" })}${lines.join("\n")}\n`; - return clipToBudget({ content: draft, maxTokens: budget.maxTokens }); - }; - - let clipped = buildContent(omissionsBase); - const omissionsFinal = ensureBudgetOmission(omissionsBase, clipped.truncated); - if (omissionsFinal !== omissionsBase) { - clipped = buildContent(omissionsFinal); - } - - const approxTokens = approxTokensFromText(clipped.content); - header.approxTokens = approxTokens; - - return { - packKey: args.packKey, - packType: "conflict", - level, - header, - content: clipped.content, - approxTokens, - maxTokens: budget.maxTokens, - truncated: clipped.truncated, - warnings: clipped.truncated ? [...warnings, "Export clipped to fit token budget."] : warnings, - clipReason: clipped.truncated ? "budget_clipped" : null, - omittedSections: (header.omissions ?? []).map((entry) => entry.sectionId) - }; -} diff --git a/apps/desktop/src/main/services/packs/packSections.test.ts b/apps/desktop/src/main/services/packs/packSections.test.ts deleted file mode 100644 index 0f0b359d..00000000 --- a/apps/desktop/src/main/services/packs/packSections.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { computeSectionChanges, replaceBetweenMarkers, upsertSectionByHeading } from "./packSections"; -import type { SectionLocator } from "./packSections"; - -const N_START = ""; -const N_END = ""; - -describe("packSections", () => { - it("replaces content between markers without touching surrounding content", () => { - const input = [ - "# Lane: demo", - "", - "## Narrative", - N_START, - "old narrative", - N_END, - "", - "---", - "*Updated: 2026-02-14T00:00:00Z*", - "" - ].join("\n"); - - const out = replaceBetweenMarkers({ - content: input, - startMarker: N_START, - endMarker: N_END, - body: "new narrative" - }).content; - - expect(out).toContain(N_START); - expect(out).toContain("new narrative"); - expect(out).toContain(N_END); - expect(out).toContain("*Updated: 2026-02-14T00:00:00Z*"); - expect(out).not.toContain("old narrative"); - }); - - it("upserts narrative markers into older packs and preserves footer after Narrative", () => { - const input = [ - "# Lane: demo", - "", - "## Narrative", - "old narrative", - "", - "---", - "*Updated: 2026-02-14T00:00:00Z*", - "" - ].join("\n"); - - const { content: out, insertedMarkers } = upsertSectionByHeading({ - content: input, - heading: "## Narrative", - startMarker: N_START, - endMarker: N_END, - body: "new narrative" - }); - - expect(insertedMarkers).toBe(true); - expect(out).toContain("## Narrative"); - expect(out).toContain(N_START); - expect(out).toContain("new narrative"); - expect(out).toContain(N_END); - expect(out).toContain("---"); - expect(out).toContain("*Updated: 2026-02-14T00:00:00Z*"); - expect(out).not.toContain("old narrative"); - }); - - it("is idempotent when markers already exist", () => { - const input = [ - "# Lane: demo", - "", - "## Narrative", - N_START, - "new narrative", - N_END, - "", - "## Other Section", - "keep me", - "" - ].join("\n"); - - const first = upsertSectionByHeading({ - content: input, - heading: "## Narrative", - startMarker: N_START, - endMarker: N_END, - body: "new narrative" - }); - const second = upsertSectionByHeading({ - content: first.content, - heading: "## Narrative", - startMarker: N_START, - endMarker: N_END, - body: "new narrative" - }); - - expect(first.insertedMarkers).toBe(false); - expect(second.insertedMarkers).toBe(false); - expect(second.content).toBe(first.content); - expect(second.content).toContain("## Other Section"); - expect(second.content).toContain("keep me"); - }); - - it("computeSectionChanges is deterministic and null-safe", () => { - const locators: SectionLocator[] = [ - { id: "narrative", kind: "markers", startMarker: N_START, endMarker: N_END }, - { id: "other", kind: "heading", heading: "## Other Section" } - ]; - - const before = [ - "# Lane: demo", - "", - "## Narrative", - N_START, - "old", - N_END, - "", - "## Other Section", - "keep", - "" - ].join("\n"); - - const after = [ - "# Lane: demo", - "", - "## Narrative", - N_START, - "new", - N_END, - "", - "## Other Section", - "keep", - "" - ].join("\n"); - - const first = computeSectionChanges({ before, after, locators }); - const second = computeSectionChanges({ before, after, locators }); - expect(first).toEqual(second); - expect(first.some((c) => c.sectionId === "narrative" && c.changeType === "modified")).toBe(true); - - const nullSafe = computeSectionChanges({ before: null, after, locators }); - expect(nullSafe.some((c) => c.sectionId === "narrative")).toBe(true); - }); -}); diff --git a/apps/desktop/src/main/services/packs/packSections.ts b/apps/desktop/src/main/services/packs/packSections.ts deleted file mode 100644 index c8f03535..00000000 --- a/apps/desktop/src/main/services/packs/packSections.ts +++ /dev/null @@ -1,165 +0,0 @@ -type UpsertByHeadingArgs = { - content: string; - heading: string; // e.g. "## Narrative" - startMarker: string; - endMarker: string; - body: string; -}; - -export type SectionLocator = - | { id: string; kind: "markers"; startMarker: string; endMarker: string } - | { id: string; kind: "heading"; heading: string }; - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function findLineEnd(content: string, fromIndex: number): number { - const idx = content.indexOf("\n", fromIndex); - return idx >= 0 ? idx + 1 : content.length; -} - -function findNextIndex(content: string, regex: RegExp, fromIndex: number): number { - let flags = regex.flags; - if (!flags.includes("g")) flags += "g"; - if (!flags.includes("m")) flags += "m"; - const re = new RegExp(regex.source, flags); - re.lastIndex = Math.max(0, fromIndex); - const match = re.exec(content); - return match ? match.index : -1; -} - -export function extractBetweenMarkers(content: string, startMarker: string, endMarker: string): string | null { - const startIdx = content.indexOf(startMarker); - const endIdx = content.indexOf(endMarker); - if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) return null; - const body = content.slice(startIdx + startMarker.length, endIdx).trim(); - return body.length ? body : ""; -} - -export function replaceBetweenMarkers(args: { - content: string; - startMarker: string; - endMarker: string; - body: string; -}): { content: string; changed: boolean } { - const startIdx = args.content.indexOf(args.startMarker); - const endIdx = args.content.indexOf(args.endMarker); - if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) { - return { content: args.content, changed: false }; - } - - const before = args.content.slice(0, startIdx + args.startMarker.length); - const after = args.content.slice(endIdx); - const nextBody = args.body.trim(); - const updated = `${before}\n${nextBody}\n${after}`; - return { content: updated, changed: updated !== args.content }; -} - -export function upsertSectionByHeading(args: UpsertByHeadingArgs): { content: string; insertedMarkers: boolean } { - // First preference: marker-based replacement. - const replaced = replaceBetweenMarkers({ - content: args.content, - startMarker: args.startMarker, - endMarker: args.endMarker, - body: args.body - }); - if (replaced.changed || (args.content.includes(args.startMarker) && args.content.includes(args.endMarker))) { - return { content: replaced.content, insertedMarkers: false }; - } - - // Upgrade older packs: insert markers inside an existing heading section. - const headingRe = new RegExp(`^${escapeRegExp(args.heading)}\\s*$`, "m"); - const match = headingRe.exec(args.content); - if (match?.index != null) { - const headingStart = match.index; - const headingLineEnd = findLineEnd(args.content, headingStart); - - const nextHeadingIdx = findNextIndex(args.content, /^##\s+/gm, headingLineEnd); - const nextHrIdx = findNextIndex(args.content, /^---\s*$/gm, headingLineEnd); - - const candidates = [nextHeadingIdx, nextHrIdx].filter((idx) => idx >= 0); - const sectionEnd = candidates.length ? Math.min(...candidates) : args.content.length; - - const before = args.content.slice(0, headingLineEnd); - const after = args.content.slice(sectionEnd); - const body = args.body.trim(); - const updated = `${before}${args.startMarker}\n${body}\n${args.endMarker}\n${after}`; - return { content: updated, insertedMarkers: true }; - } - - // If the heading doesn't exist, append a new section at the end. - const trimmed = args.content.trimEnd(); - const body = args.body.trim(); - const suffix = `${args.heading}\n${args.startMarker}\n${body}\n${args.endMarker}\n`; - const updated = trimmed.length ? `${trimmed}\n\n${suffix}` : `${suffix}`; - return { content: updated, insertedMarkers: true }; -} - -export function extractSectionByHeading(content: string, heading: string): string | null { - const headingRe = new RegExp(`^${escapeRegExp(heading)}\\s*$`, "m"); - const match = headingRe.exec(content); - if (!match?.index && match?.index !== 0) return null; - - const headingStart = match.index; - const headingLineEnd = findLineEnd(content, headingStart); - - const nextHeadingIdx = findNextIndex(content, /^##\s+/gm, headingLineEnd); - const nextHrIdx = findNextIndex(content, /^---\s*$/gm, headingLineEnd); - const candidates = [nextHeadingIdx, nextHrIdx].filter((idx) => idx >= 0); - const sectionEnd = candidates.length ? Math.min(...candidates) : content.length; - - const body = content.slice(headingLineEnd, sectionEnd).trim(); - return body.length ? body : ""; -} - -export function extractSectionContent(content: string, locator: SectionLocator): string | null { - if (locator.kind === "markers") return extractBetweenMarkers(content, locator.startMarker, locator.endMarker); - if (locator.kind === "heading") return extractSectionByHeading(content, locator.heading); - return null; -} - -export function computeSectionChanges(args: { - before: string | null; - after: string; - locators: SectionLocator[]; -}): Array<{ sectionId: string; changeType: "added" | "removed" | "modified" }> { - const norm = (value: string | null): string | null => { - if (value == null) return null; - return String(value).replace(/\r\n/g, "\n").trim(); - }; - - const beforeContent = args.before ?? ""; - const out: Array<{ sectionId: string; changeType: "added" | "removed" | "modified" }> = []; - - for (const locator of args.locators) { - const a = norm(extractSectionContent(beforeContent, locator)); - const b = norm(extractSectionContent(args.after, locator)); - - if (a == null && b == null) continue; - if (a == null && b != null) { - out.push({ sectionId: locator.id, changeType: "added" }); - continue; - } - if (a != null && b == null) { - out.push({ sectionId: locator.id, changeType: "removed" }); - continue; - } - if (a !== b) out.push({ sectionId: locator.id, changeType: "modified" }); - } - - return out; -} - -export function renderJsonSection(heading: string, value: unknown, opts: { pretty?: boolean } = {}): string[] { - const pretty = opts.pretty !== false; - let json = ""; - try { - json = pretty ? JSON.stringify(value ?? null, null, 2) : JSON.stringify(value ?? null); - } catch { - json = pretty - ? JSON.stringify({ error: "Failed to serialize JSON section." }, null, 2) - : JSON.stringify({ error: "Failed to serialize JSON section." }); - } - return [heading, "```json", json, "```", ""]; -} diff --git a/apps/desktop/src/main/services/packs/packService.docsFreshness.test.ts b/apps/desktop/src/main/services/packs/packService.docsFreshness.test.ts deleted file mode 100644 index 1ce30a25..00000000 --- a/apps/desktop/src/main/services/packs/packService.docsFreshness.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { openKvDb } from "../state/kvDb"; -import { createPackService } from "./packService"; - -function git(cwd: string, args: string[]): string { - const res = spawnSync("git", args, { cwd, encoding: "utf8" }); - if (res.status !== 0) { - throw new Error(`git ${args.join(" ")} failed: ${res.stderr || res.stdout}`); - } - return (res.stdout ?? "").trim(); -} - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -function readContextFingerprint(body: string): string { - const line = body - .split(/\r?\n/) - .find((entry) => entry.startsWith("Context fingerprint:")); - const value = (line ?? "").split(":").slice(1).join(":").trim(); - if (!value) throw new Error("Context fingerprint line missing"); - return value; -} - -describe("packService docs freshness", () => { - it("refreshes project context fingerprint when docs change", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pack-docs-")); - const packsDir = path.join(projectRoot, ".ade", "packs"); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, "docs", "features"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nInitial\n", "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "SYSTEM.md"), "# System\n\nv1\n", "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "features", "CONFLICTS.md"), "# Conflicts\n\nv1\n", "utf8"); - git(projectRoot, ["init", "-b", "main"]); - git(projectRoot, ["config", "user.email", "ade@test.local"]); - git(projectRoot, ["config", "user.name", "ADE Test"]); - git(projectRoot, ["add", "."]); - git(projectRoot, ["commit", "-m", "init docs"]); - - const db = await openKvDb(path.join(projectRoot, "kv.sqlite"), createLogger()); - const projectId = "proj-docs"; - const now = "2026-02-15T19:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, projectRoot, "demo", "main", now, now] - ); - - const packService = createPackService({ - db, - logger: createLogger(), - projectRoot, - projectId, - packsDir, - laneService: { - list: async () => [], - getLaneBaseAndBranch: () => ({ worktreePath: projectRoot, baseRef: "main", branchRef: "main" }) - } as any, - sessionService: { readTranscriptTail: () => "" } as any, - projectConfigService: { - get: () => ({ - local: { providers: {} }, - effective: { - providerMode: "guest", - providers: {}, - processes: [], - testSuites: [], - stackButtons: [] - } - }) - } as any, - operationService: { start: () => ({ operationId: "op-1" }), finish: () => {} } as any - }); - - const first = await packService.refreshProjectPack({ reason: "onboarding_init" }); - const firstFingerprint = readContextFingerprint(first.body); - - fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "SYSTEM.md"), "# System\n\nv2 changed\n", "utf8"); - - const second = await packService.refreshProjectPack({ reason: "docs_churn" }); - const secondFingerprint = readContextFingerprint(second.body); - - expect(secondFingerprint).not.toBe(firstFingerprint); - - const exportLite = await packService.getProjectExport({ level: "lite" }); - expect(exportLite.content).toContain(secondFingerprint); - expect(exportLite.content).toContain("\"contextVersion\""); - expect(exportLite.content).toContain("\"lastDocsRefreshAt\""); - }); -}); diff --git a/apps/desktop/src/main/services/packs/packService.missionPack.test.ts b/apps/desktop/src/main/services/packs/packService.missionPack.test.ts deleted file mode 100644 index 91c5a3c0..00000000 --- a/apps/desktop/src/main/services/packs/packService.missionPack.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { createPackService } from "./packService"; -import { openKvDb } from "../state/kvDb"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -describe("packService mission pack", () => { - it("refreshes mission packs with durable version/event metadata", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pack-mission-")); - const packsDir = path.join(projectRoot, ".ade", "packs"); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nMission pack tests\n", "utf8"); - - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-02-19T00:00:00.000Z"; - - db.run( - ` - insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?) - `, - [projectId, projectRoot, "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, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - laneId, - projectId, - "Lane 1", - null, - "worktree", - "main", - "feature/lane-1", - projectRoot, - null, - 0, - null, - null, - null, - null, - "active", - now, - null - ] - ); - db.run( - ` - insert into missions( - id, - project_id, - lane_id, - title, - prompt, - status, - priority, - execution_mode, - target_machine_id, - outcome_summary, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [missionId, projectId, laneId, "Mission 1", "Implement context hardening gate.", "in_progress", "high", "local", null, null, null, null, now, now, now, null] - ); - db.run( - ` - insert into mission_steps( - id, - mission_id, - project_id, - step_index, - title, - detail, - kind, - lane_id, - status, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ["mstep-1", missionId, projectId, 0, "Design runtime contracts", null, "manual", laneId, "running", null, now, now, now, null] - ); - db.run( - ` - insert into orchestrator_runs( - id, - project_id, - mission_id, - status, - context_profile, - scheduler_state, - runtime_cursor_json, - last_error, - metadata_json, - created_at, - updated_at, - started_at, - completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ["run-1", projectId, missionId, "active", "orchestrator_deterministic_v1", "active", null, null, null, now, now, now, null] - ); - db.run( - ` - insert into orchestrator_steps( - id, run_id, project_id, step_key, title, status, step_index, - created_at, updated_at - ) values (?, ?, ?, 'step-1', 'Design runtime contracts', 'running', 0, ?, ?) - `, - ["ostep-1", "run-1", projectId, now, now] - ); - db.run( - ` - insert into orchestrator_attempts( - id, run_id, step_id, project_id, attempt_number, status, - executor_kind, context_profile, created_at - ) values (?, ?, ?, ?, 1, 'running', 'codex', 'orchestrator_deterministic_v1', ?) - `, - ["attempt-1", "run-1", "ostep-1", projectId, now] - ); - db.run( - ` - insert into mission_step_handoffs( - id, - project_id, - mission_id, - mission_step_id, - run_id, - step_id, - attempt_id, - handoff_type, - producer, - payload_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ["handoff-1", projectId, missionId, "mstep-1", "run-1", "ostep-1", "attempt-1", "attempt_started", "orchestrator", "{\"summary\":\"started\"}", now] - ); - - const packService = createPackService({ - db, - logger: createLogger(), - projectRoot, - projectId, - packsDir, - laneService: { - list: async () => [ - { - id: laneId, - name: "Lane 1", - description: null, - laneType: "worktree", - baseRef: "main", - branchRef: "feature/lane-1", - worktreePath: projectRoot, - attachedRootPath: null, - isEditProtected: false, - parentLaneId: null, - color: null, - icon: null, - tags: [], - status: { dirty: false, ahead: 0, behind: 0 }, - createdAt: now, - archivedAt: null, - stackDepth: 0 - } - ], - getLaneBaseAndBranch: () => ({ worktreePath: projectRoot, baseRef: "main", branchRef: "feature/lane-1" }) - } as any, - sessionService: { readTranscriptTail: () => "" } as any, - projectConfigService: { - get: () => ({ - local: { providers: {} }, - effective: { - providerMode: "guest", - providers: {}, - processes: [], - testSuites: [], - stackButtons: [] - } - }) - } as any, - operationService: { - start: () => ({ operationId: "op-1" }), - finish: () => {} - } as any - }); - - const refreshed = await packService.refreshMissionPack({ - missionId, - reason: "test_refresh", - runId: "run-1" - }); - - expect(refreshed.packType).toBe("mission"); - expect(refreshed.packKey).toBe(`mission:${missionId}`); - expect(refreshed.exists).toBe(true); - expect(refreshed.versionId).toBeTruthy(); - expect(refreshed.versionNumber).toBeGreaterThan(0); - expect(refreshed.contentHash).toBeTruthy(); - expect(refreshed.body).toContain("Mission Pack:"); - expect(refreshed.body).toContain("Step Handoffs"); - expect(refreshed.body).toContain("attempt_started"); - expect(refreshed.body).toContain("Orchestrator Runs"); - - const fetched = packService.getMissionPack(missionId); - expect(fetched.exists).toBe(true); - expect(fetched.packType).toBe("mission"); - expect(fetched.versionId).toBe(refreshed.versionId); - expect(fetched.versionNumber).toBe(refreshed.versionNumber); - - const versionCount = db.get<{ count: number }>( - "select count(*) as count from pack_versions where project_id = ? and pack_key = ?", - [projectId, `mission:${missionId}`] - ); - expect(Number(versionCount?.count ?? 0)).toBeGreaterThan(0); - - const refreshEventCount = db.get<{ count: number }>( - "select count(*) as count from pack_events where project_id = ? and pack_key = ? and event_type = ?", - [projectId, `mission:${missionId}`, "refresh_triggered"] - ); - expect(Number(refreshEventCount?.count ?? 0)).toBeGreaterThan(0); - - const indexRow = db.get<{ metadata_json: string | null; pack_type: string }>( - "select metadata_json, pack_type from packs_index where project_id = ? and pack_key = ? limit 1", - [projectId, `mission:${missionId}`] - ); - expect(indexRow?.pack_type).toBe("mission"); - const metadata = (() => { - try { - return indexRow?.metadata_json ? (JSON.parse(indexRow.metadata_json) as Record) : null; - } catch { - return null; - } - })(); - expect(metadata?.reason).toBe("test_refresh"); - expect(metadata?.runId).toBe("run-1"); - expect(typeof metadata?.versionId).toBe("string"); - expect(typeof metadata?.contentHash).toBe("string"); - - db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/packs/packService.ts b/apps/desktop/src/main/services/packs/packService.ts deleted file mode 100644 index 53af3caa..00000000 --- a/apps/desktop/src/main/services/packs/packService.ts +++ /dev/null @@ -1,2680 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { createContextDocService } from "../context/contextDocService"; -import { runGit } from "../git/git"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import type { createLaneService } from "../lanes/laneService"; -import type { createSessionService } from "../sessions/sessionService"; -import { createSessionDeltaService } from "../sessions/sessionDeltaService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createOperationService } from "../history/operationService"; -import { nowIso, uniqueSorted } from "../shared/utils"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import type { - Checkpoint, - ConflictLineageV1, - ContextExportLevel, - ContextHeaderV1, - ConflictRiskLevel, - ConflictStatusValue, - GetConflictExportArgs, - GetLaneExportArgs, - GetProjectExportArgs, - LaneCompletionSignal, - LaneExportManifestV1, - LaneSummary, - OrchestratorLaneSummaryV1, - ListPackEventsSinceArgs, - PackDeltaDigestArgs, - PackDeltaDigestV1, - PackDependencyStateV1, - PackEvent, - PackEventCategory, - PackEventEntityRef, - PackEventImportance, - PackExport, - PackHeadVersion, - PackSummary, - PackType, - PackVersion, - PackVersionSummary, - ProjectExportManifestV1, - ProjectManifestLaneEntryV1, - SessionDeltaSummary, - TestRunStatus -} from "../../../shared/types"; -import { - ADE_INTENT_END, - ADE_INTENT_START, - ADE_NARRATIVE_END, - ADE_NARRATIVE_START, - ADE_TASK_SPEC_END, - ADE_TASK_SPEC_START, - ADE_TODOS_END, - ADE_TODOS_START, - CONTEXT_HEADER_SCHEMA_V1, - CONTEXT_CONTRACT_VERSION -} from "../../../shared/contextContract"; -import { stripAnsi } from "../../utils/ansiStrip"; -import { inferTestOutcomeFromText } from "./transcriptInsights"; -import { renderLanePackMarkdown } from "./lanePackTemplate"; -import { computeSectionChanges, upsertSectionByHeading } from "./packSections"; -import { buildConflictExport, buildLaneExport, buildProjectExport } from "./packExports"; -import type { PackRelation } from "../../../shared/contextContract"; - -// ── Extracted builder modules ──────────────────────────────────────────────── -import { - safeJsonParseArray, - readFileIfExists, - ensureDirFor, - ensureDir, - safeSegment, - parsePackMetadataJson, - extractSection, - statusFromCode, - humanToolLabel, - normalizeRiskLevel, - asString, - computeMergeReadiness, - buildGraphEnvelope, - importanceRank, - getDefaultSectionLocators, - upsertPackIndex, - toPackSummaryFromRow, - formatCommand, - moduleFromPath -} from "./packUtils"; -import { - type ProjectPackBuilderDeps, - buildProjectPackBody as buildProjectPackBodyImpl -} from "./projectPackBuilder"; -import { - type MissionPackBuilderDeps, - buildMissionPackBody as buildMissionPackBodyImpl, - buildPlanPackBody as buildPlanPackBodyImpl, - buildFeaturePackBody as buildFeaturePackBodyImpl -} from "./missionPackBuilder"; -import { - type ConflictPackBuilderDeps, - readConflictPredictionPack as readConflictPredictionPackImpl, - readGitConflictState as readGitConflictStateImpl, - deriveConflictStateForLane as deriveConflictStateForLaneImpl, - computeLaneLineage as computeLaneLineageImpl, - buildLaneConflictRiskSummaryLines as buildLaneConflictRiskSummaryLinesImpl, - buildConflictPackBody as buildConflictPackBodyImpl -} from "./conflictPackBuilder"; - -function replaceNarrativeSection(existing: string, narrative: string): { updated: string; insertedMarkers: boolean } { - const cleanNarrative = narrative.trim().length ? narrative.trim() : "Narrative generation returned empty content."; - const next = upsertSectionByHeading({ - content: existing, - heading: "## Narrative", - startMarker: ADE_NARRATIVE_START, - endMarker: ADE_NARRATIVE_END, - body: cleanNarrative - }); - return { updated: next.content, insertedMarkers: next.insertedMarkers }; -} - -export function createPackService({ - db, - logger, - projectRoot, - projectId, - packsDir, - laneService, - sessionService, - projectConfigService, - aiIntegrationService, - operationService, - onEvent -}: { - db: AdeDb; - logger: Logger; - projectRoot: string; - projectId: string; - packsDir: string; - laneService: ReturnType; - sessionService: ReturnType; - projectConfigService: ReturnType; - aiIntegrationService?: ReturnType; - operationService: ReturnType; - onEvent?: (event: PackEvent) => void; -}) { - const projectPackPath = path.join(packsDir, "project_pack.md"); - - const getLanePackPath = (laneId: string) => path.join(packsDir, "lanes", laneId, "lane_pack.md"); - const getFeaturePackPath = (featureKey: string) => path.join(packsDir, "features", safeSegment(featureKey), "feature_pack.md"); - const getPlanPackPath = (laneId: string) => path.join(packsDir, "plans", laneId, "plan_pack.md"); - const getMissionPackPath = (missionId: string) => path.join(packsDir, "missions", missionId, "mission_pack.md"); - const getConflictPackPath = (laneId: string, peer: string) => - path.join(packsDir, "conflicts", "v2", `${laneId}__${safeSegment(peer)}.md`); - const conflictsRootDir = path.join(packsDir, "conflicts"); - const conflictPredictionsDir = path.join(conflictsRootDir, "predictions"); - const getConflictPredictionPath = (laneId: string) => path.join(conflictPredictionsDir, `${laneId}.json`); - - const versionsDir = path.join(packsDir, "versions"); - const historyDir = path.join(path.dirname(packsDir), "history"); - const checkpointsDir = path.join(historyDir, "checkpoints"); - const eventsDir = path.join(historyDir, "events"); - - const sha256 = (input: string): string => createHash("sha256").update(input).digest("hex"); - - const inferPackTypeFromKey = (packKey: string): PackType => { - if (packKey === "project") return "project"; - if (packKey.startsWith("lane:")) return "lane"; - if (packKey.startsWith("feature:")) return "feature"; - if (packKey.startsWith("conflict:")) return "conflict"; - if (packKey.startsWith("plan:")) return "plan"; - if (packKey.startsWith("mission:")) return "mission"; - return "project"; - }; - - // ── Deps for extracted builders ───────────────────────────────────────────── - - const projectPackBuilderDeps: ProjectPackBuilderDeps = { - db, - logger, - projectRoot, - projectId, - packsDir, - laneService, - projectConfigService, - aiIntegrationService - }; - const contextDocService = createContextDocService({ - ...projectPackBuilderDeps, - packsDir, - }); - - const conflictPackBuilderDeps: ConflictPackBuilderDeps = { - projectRoot, - laneService, - getConflictPredictionPath, - getLanePackPath - }; - - const readConflictPredictionPack = (laneId: string) => readConflictPredictionPackImpl(conflictPackBuilderDeps, laneId); - const readGitConflictState = (laneId: string) => readGitConflictStateImpl(conflictPackBuilderDeps, laneId); - const deriveConflictStateForLane = (laneId: string) => deriveConflictStateForLaneImpl(conflictPackBuilderDeps, laneId); - const computeLaneLineage = (args: { laneId: string; lanesById: Map }) => computeLaneLineageImpl(args); - const buildLaneConflictRiskSummaryLines = (laneId: string) => buildLaneConflictRiskSummaryLinesImpl(conflictPackBuilderDeps, laneId); - - const readContextDocMeta = () => contextDocService.getDocMeta(); - - const buildProjectPackBody = (args: { reason: string; deterministicUpdatedAt: string; sourceLaneId?: string }) => - buildProjectPackBodyImpl(projectPackBuilderDeps, args); - - // Mission builder deps are defined lazily (below) because they reference closures - // (getHeadSha, getPackIndexRow) that are defined later in this function. - // See `missionPackBuilderDeps` and its wrappers below the core infrastructure section. - - const findBaselineVersionAtOrBefore = (args: { packKey: string; sinceIso: string }): { id: string; versionNumber: number; createdAt: string } | null => { - const row = db.get<{ id: string; version_number: number; created_at: string }>( - ` - select id, version_number, created_at - from pack_versions - where project_id = ? - and pack_key = ? - and created_at <= ? - order by created_at desc - limit 1 - `, - [projectId, args.packKey, args.sinceIso] - ); - if (!row?.id) return null; - return { id: row.id, versionNumber: Number(row.version_number ?? 0), createdAt: row.created_at }; - }; - - const classifyPackEvent = (args: { - packKey: string; - eventType: string; - createdAt: string; - payload: Record; - }): { - importance: PackEventImportance; - importanceScore: number; - category: PackEventCategory; - entityIds: string[]; - entityRefs: PackEventEntityRef[]; - actionType: string; - rationale: string | null; - } => { - const eventType = args.eventType; - const payload = args.payload ?? {}; - - const entityIdsSet = new Set(); - const entityRefs: PackEventEntityRef[] = []; - - const addEntity = (kind: string, idRaw: unknown) => { - const id = typeof idRaw === "string" ? idRaw.trim() : ""; - if (!id) return; - entityIdsSet.add(id); - entityRefs.push({ kind, id }); - }; - - if (args.packKey.startsWith("lane:")) addEntity("lane", args.packKey.slice("lane:".length)); - if (args.packKey.startsWith("conflict:")) { - const parts = args.packKey.split(":"); - if (parts.length >= 2) addEntity("lane", parts[1]); - if (parts.length >= 3) addEntity("peer", parts.slice(2).join(":")); - } - - addEntity("lane", payload.laneId); - addEntity("lane", payload.peerLaneId); - addEntity("session", payload.sessionId); - addEntity("checkpoint", payload.checkpointId); - addEntity("version", payload.versionId); - addEntity("operation", payload.operationId); - addEntity("job", payload.jobId); - addEntity("artifact", payload.artifactId); - addEntity("proposal", payload.proposalId); - - const category: PackEventCategory = (() => { - if (eventType.startsWith("narrative_")) return "narrative"; - if (eventType === "checkpoint") return "session"; - if (eventType.includes("conflict")) return "conflict"; - if (eventType.includes("branch")) return "branch"; - return "pack"; - })(); - - const importance: PackEventImportance = (() => { - if (eventType === "narrative_update") return "high"; - if (eventType === "narrative_failed") return "high"; - if (eventType === "checkpoint") return "medium"; - if (eventType === "refresh_triggered") return "medium"; - if (eventType === "narrative_requested") return "medium"; - return "low"; - })(); - - const importanceScore = importance === "high" ? 0.9 : importance === "medium" ? 0.6 : 0.25; - - const rationale = (() => { - const trigger = typeof payload.trigger === "string" ? payload.trigger.trim() : ""; - if (trigger) return trigger; - const source = typeof payload.source === "string" ? payload.source.trim() : ""; - if (source) return source; - return null; - })(); - - return { - importance, - importanceScore, - category, - entityIds: Array.from(entityIdsSet), - entityRefs, - actionType: eventType, - rationale - }; - }; - - const ensureEventMeta = (event: PackEvent): PackEvent => { - const payload = (event.payload ?? {}) as Record; - const hasMeta = - payload.importance != null || - payload.importanceScore != null || - payload.category != null || - payload.entityIds != null || - payload.entityRefs != null || - payload.actionType != null || - payload.rationale != null; - if (hasMeta) return event; - - const meta = classifyPackEvent({ - packKey: event.packKey, - eventType: event.eventType, - createdAt: event.createdAt, - payload - }); - - return { - ...event, - payload: { - ...payload, - importance: meta.importance, - importanceScore: meta.importanceScore, - category: meta.category, - entityIds: meta.entityIds, - entityRefs: meta.entityRefs, - actionType: meta.actionType, - rationale: meta.rationale - } - }; - }; - - const upsertEventMetaForInsert = (args: { - packKey: string; - eventType: string; - createdAt: string; - payload: Record; - }): Record => { - const payload = args.payload ?? {}; - const out: Record = { ...payload }; - const meta = classifyPackEvent(args); - - if (out.importance == null) out.importance = meta.importance; - if (out.importanceScore == null) out.importanceScore = meta.importanceScore; - if (out.category == null) out.category = meta.category; - if (out.entityIds == null) out.entityIds = meta.entityIds; - if (out.entityRefs == null) out.entityRefs = meta.entityRefs; - if (out.actionType == null) out.actionType = meta.actionType; - if (out.rationale == null) out.rationale = meta.rationale; - - return out; - }; - - const readGatewayMeta = (): { apiBaseUrl: string | null; remoteProjectId: string | null } => { - return { apiBaseUrl: null, remoteProjectId: null }; - }; - - const PACK_RETENTION_KEEP_DAYS = 14; - const PACK_RETENTION_MAX_ARCHIVED_LANES = 25; - const PACK_RETENTION_CLEANUP_INTERVAL_MS = 60 * 60_000; - let lastCleanupAt = 0; - - const cleanupPacks = async (): Promise => { - const lanes = await laneService.list({ includeArchived: true }); - const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); - - const now = Date.now(); - const keepBeforeMs = now - PACK_RETENTION_KEEP_DAYS * 24 * 60 * 60_000; - - const lanesDir = path.join(packsDir, "lanes"); - const conflictsDir = path.join(packsDir, "conflicts"); - - const archivedDirs: Array<{ laneId: string; archivedAtMs: number }> = []; - - if (fs.existsSync(lanesDir)) { - for (const entry of fs.readdirSync(lanesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const laneId = entry.name; - const lane = laneById.get(laneId); - const absDir = path.join(lanesDir, laneId); - - if (!lane) { - fs.rmSync(absDir, { recursive: true, force: true }); - continue; - } - - if (!lane.archivedAt) continue; - const ts = Date.parse(lane.archivedAt); - const archivedAtMs = Number.isFinite(ts) ? ts : now; - archivedDirs.push({ laneId, archivedAtMs }); - } - } - - archivedDirs.sort((a, b) => b.archivedAtMs - a.archivedAtMs); - const keepByCount = new Set(archivedDirs.slice(0, PACK_RETENTION_MAX_ARCHIVED_LANES).map((entry) => entry.laneId)); - - for (const { laneId, archivedAtMs } of archivedDirs) { - if (keepByCount.has(laneId) && archivedAtMs >= keepBeforeMs) continue; - const absDir = path.join(lanesDir, laneId); - fs.rmSync(absDir, { recursive: true, force: true }); - } - - if (fs.existsSync(conflictsDir)) { - for (const entry of fs.readdirSync(conflictsDir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith(".json")) continue; - const laneId = entry.name.slice(0, -".json".length); - const lane = laneById.get(laneId); - if (!lane) { - fs.rmSync(path.join(conflictsDir, entry.name), { force: true }); - continue; - } - if (!lane.archivedAt) continue; - const ts = Date.parse(lane.archivedAt); - const archivedAtMs = Number.isFinite(ts) ? ts : now; - if (!keepByCount.has(laneId) || archivedAtMs < keepBeforeMs) { - fs.rmSync(path.join(conflictsDir, entry.name), { force: true }); - } - } - - // Conflict prediction summaries (v1) live under `conflicts/predictions/*.json`. - const predictionsDir = path.join(conflictsDir, "predictions"); - if (fs.existsSync(predictionsDir)) { - for (const entry of fs.readdirSync(predictionsDir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith(".json")) continue; - const laneId = entry.name.slice(0, -".json".length); - const lane = laneById.get(laneId); - const absPath = path.join(predictionsDir, entry.name); - if (!lane) { - fs.rmSync(absPath, { force: true }); - continue; - } - if (!lane.archivedAt) continue; - const ts = Date.parse(lane.archivedAt); - const archivedAtMs = Number.isFinite(ts) ? ts : now; - if (!keepByCount.has(laneId) || archivedAtMs < keepBeforeMs) { - fs.rmSync(absPath, { force: true }); - } - } - } - - // V2 conflict packs are stored as markdown files under `conflicts/v2/`. - const v2Dir = path.join(conflictsDir, "v2"); - if (fs.existsSync(v2Dir)) { - for (const entry of fs.readdirSync(v2Dir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith(".md")) continue; - const file = entry.name; - const laneId = file.split("__")[0]?.trim() ?? ""; - if (!laneId) continue; - - const lane = laneById.get(laneId); - const absPath = path.join(v2Dir, file); - if (!lane) { - fs.rmSync(absPath, { force: true }); - continue; - } - if (!lane.archivedAt) continue; - const ts = Date.parse(lane.archivedAt); - const archivedAtMs = Number.isFinite(ts) ? ts : now; - if (!keepByCount.has(laneId) || archivedAtMs < keepBeforeMs) { - fs.rmSync(absPath, { force: true }); - } - } - } - } - }; - - const maybeCleanupPacks = () => { - const now = Date.now(); - if (now - lastCleanupAt < PACK_RETENTION_CLEANUP_INTERVAL_MS) return; - lastCleanupAt = now; - void cleanupPacks().catch((error: unknown) => { - logger.warn("packs.cleanup_failed", { - error: error instanceof Error ? error.message : String(error) - }); - }); - }; - - const readCurrentPackVersion = (packKey: string): { versionId: string; versionNumber: number; contentHash: string; renderedPath: string } | null => { - const row = db.get<{ id: string; version_number: number; content_hash: string; rendered_path: string }>( - ` - select v.id as id, v.version_number as version_number, v.content_hash as content_hash, v.rendered_path as rendered_path - from pack_heads h - join pack_versions v on v.id = h.current_version_id and v.project_id = h.project_id - where h.project_id = ? - and h.pack_key = ? - limit 1 - `, - [projectId, packKey] - ); - if (!row?.id) return null; - return { - versionId: row.id, - versionNumber: Number(row.version_number ?? 0), - contentHash: String(row.content_hash ?? ""), - renderedPath: String(row.rendered_path ?? "") - }; - }; - - const createPackEvent = (args: { packKey: string; eventType: string; payload?: Record }): PackEvent => { - const eventId = randomUUID(); - const createdAt = nowIso(); - const payload = upsertEventMetaForInsert({ - packKey: args.packKey, - eventType: args.eventType, - createdAt, - payload: args.payload ?? {} - }); - - db.run( - ` - insert into pack_events( - id, - project_id, - pack_key, - event_type, - payload_json, - created_at - ) values (?, ?, ?, ?, ?, ?) - `, - [eventId, projectId, args.packKey, args.eventType, JSON.stringify(payload), createdAt] - ); - - const event: PackEvent = ensureEventMeta({ id: eventId, packKey: args.packKey, eventType: args.eventType, payload, createdAt }); - - try { - const monthKey = createdAt.slice(0, 7); // YYYY-MM - const monthDir = path.join(eventsDir, monthKey); - ensureDir(monthDir); - fs.writeFileSync( - path.join(monthDir, `${eventId}.json`), - JSON.stringify(event, null, 2), - "utf8" - ); - } catch { - // ignore event file write failures - } - - try { - onEvent?.(event); - } catch { - // ignore broadcast failures - } - - return event; - }; - - const createPackVersion = (args: { packKey: string; packType: PackType; body: string }): { versionId: string; versionNumber: number; contentHash: string } => { - const bodyHash = sha256(args.body); - const existing = readCurrentPackVersion(args.packKey); - if (existing && existing.contentHash === bodyHash) { - return { - versionId: existing.versionId, - versionNumber: existing.versionNumber, - contentHash: existing.contentHash - }; - } - - const versionId = randomUUID(); - const createdAt = nowIso(); - const maxRow = db.get<{ max_version: number | null }>( - "select max(version_number) as max_version from pack_versions where project_id = ? and pack_key = ?", - [projectId, args.packKey] - ); - const versionNumber = Number(maxRow?.max_version ?? 0) + 1; - const renderedPath = path.join(versionsDir, `${versionId}.md`); - - ensureDir(versionsDir); - fs.writeFileSync(renderedPath, args.body, "utf8"); - - db.run( - ` - insert into pack_versions( - id, - project_id, - pack_key, - version_number, - content_hash, - rendered_path, - created_at - ) values (?, ?, ?, ?, ?, ?, ?) - `, - [versionId, projectId, args.packKey, versionNumber, bodyHash, renderedPath, createdAt] - ); - - db.run( - ` - insert into pack_heads(project_id, pack_key, current_version_id, updated_at) - values (?, ?, ?, ?) - on conflict(project_id, pack_key) do update set - current_version_id = excluded.current_version_id, - updated_at = excluded.updated_at - `, - [projectId, args.packKey, versionId, createdAt] - ); - - createPackEvent({ - packKey: args.packKey, - eventType: "version_created", - payload: { - packKey: args.packKey, - packType: args.packType, - versionId, - versionNumber, - contentHash: bodyHash - } - }); - - return { versionId, versionNumber, contentHash: bodyHash }; - }; - - const persistPackRefresh = (args: { - packKey: string; - packType: PackType; - packPath: string; - laneId: string | null; - body: string; - deterministicUpdatedAt: string; - narrativeUpdatedAt?: string | null; - lastHeadSha?: string | null; - metadata?: Record; - eventType?: string; - eventPayload?: Record; - }): PackSummary => { - ensureDirFor(args.packPath); - fs.writeFileSync(args.packPath, args.body, "utf8"); - - createPackEvent({ - packKey: args.packKey, - eventType: args.eventType ?? "refresh_triggered", - payload: args.eventPayload ?? {} - }); - - const version = createPackVersion({ packKey: args.packKey, packType: args.packType, body: args.body }); - const metadata = { - ...(args.metadata ?? {}), - versionId: version.versionId, - versionNumber: version.versionNumber, - contentHash: version.contentHash - }; - - upsertPackIndex({ - db, - projectId, - packKey: args.packKey, - laneId: args.laneId, - packType: args.packType, - packPath: args.packPath, - deterministicUpdatedAt: args.deterministicUpdatedAt, - narrativeUpdatedAt: args.narrativeUpdatedAt ?? null, - lastHeadSha: args.lastHeadSha ?? null, - metadata - }); - - maybeCleanupPacks(); - - return { - packKey: args.packKey, - packType: args.packType, - path: args.packPath, - exists: true, - deterministicUpdatedAt: args.deterministicUpdatedAt, - narrativeUpdatedAt: args.narrativeUpdatedAt ?? null, - lastHeadSha: args.lastHeadSha ?? null, - versionId: version.versionId, - versionNumber: version.versionNumber, - contentHash: version.contentHash, - metadata, - body: args.body - }; - }; - - const recordCheckpointFromDelta = (args: { - laneId: string; - sessionId: string; - sha: string; - delta: SessionDeltaSummary; - }): Checkpoint | null => { - const existing = db.get<{ id: string }>( - "select id from checkpoints where project_id = ? and session_id = ? limit 1", - [projectId, args.sessionId] - ); - if (existing?.id) return null; - - const checkpointId = randomUUID(); - const createdAt = nowIso(); - const diffStat = { - insertions: args.delta.insertions, - deletions: args.delta.deletions, - filesChanged: args.delta.filesChanged, - files: args.delta.touchedFiles - }; - - const event = createPackEvent({ - packKey: `lane:${args.laneId}`, - eventType: "checkpoint", - payload: { - checkpointId, - laneId: args.laneId, - sessionId: args.sessionId, - sha: args.sha, - diffStat - } - }); - - try { - ensureDir(checkpointsDir); - fs.writeFileSync( - path.join(checkpointsDir, `${checkpointId}.json`), - JSON.stringify( - { - id: checkpointId, - laneId: args.laneId, - sessionId: args.sessionId, - sha: args.sha, - diffStat, - packEventIds: [event.id], - createdAt - }, - null, - 2 - ), - "utf8" - ); - } catch { - // ignore checkpoint file write failures - } - - db.run( - ` - insert into checkpoints( - id, - project_id, - lane_id, - session_id, - sha, - diff_stat_json, - pack_event_ids_json, - created_at - ) values (?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - checkpointId, - projectId, - args.laneId, - args.sessionId, - args.sha, - JSON.stringify(diffStat), - JSON.stringify([event.id]), - createdAt - ] - ); - - return { - id: checkpointId, - laneId: args.laneId, - sessionId: args.sessionId, - sha: args.sha, - diffStat, - packEventIds: [event.id], - createdAt - }; - }; - - const getHeadSha = async (worktreePath: string): Promise => { - const res = await runGit(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 8_000 }); - if (res.exitCode !== 0) return null; - const sha = res.stdout.trim(); - return sha.length ? sha : null; - }; - const sessionDeltaService = createSessionDeltaService({ db, projectId, laneService, sessionService }); - - const buildLanePackBody = async ({ - laneId, - reason, - latestDelta, - deterministicUpdatedAt - }: { - laneId: string; - reason: string; - latestDelta: SessionDeltaSummary | null; - deterministicUpdatedAt: string; - }): Promise<{ body: string; lastHeadSha: string | null }> => { - const lanes = await laneService.list({ includeArchived: true }); - const lane = lanes.find((candidate) => candidate.id === laneId); - if (!lane) throw new Error(`Lane not found: ${laneId}`); - const primaryLane = lanes.find((candidate) => candidate.laneType === "primary") ?? null; - const parentLane = lane.parentLaneId ? lanes.find((candidate) => candidate.id === lane.parentLaneId) ?? null : null; - - const existingBody = readFileIfExists(getLanePackPath(laneId)); - const userIntent = extractSection(existingBody, ADE_INTENT_START, ADE_INTENT_END, "Intent not set — click to add."); - const userTodos = extractSection(existingBody, ADE_TODOS_START, ADE_TODOS_END, ""); - - const taskSpecFallback = [ - "Problem Statement:", - "- (what are we solving, and for whom?)", - "", - "Scope:", - "- (what is included?)", - "", - "Non-goals:", - "- (what is explicitly out of scope?)", - "", - "Acceptance Criteria:", - "- [ ] (add checkable acceptance criteria)", - "", - "Constraints / Conventions:", - "- (languages, frameworks, patterns, performance, security, etc.)", - "", - "Dependencies:", - `- Parent lane: ${parentLane ? parentLane.name : "(none)"}`, - "- Required merges: (list lanes/PRs that must land first)" - ].join("\n"); - const taskSpec = extractSection(existingBody, ADE_TASK_SPEC_START, ADE_TASK_SPEC_END, taskSpecFallback); - - const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; - const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); - const headSha = await getHeadSha(worktreePath); - - const isoTime = (value: string | null | undefined) => { - const raw = typeof value === "string" ? value : ""; - return raw.length >= 16 ? raw.slice(11, 16) : raw; - }; - - const recentSessions = db.all<{ - id: string; - title: string; - goal: string | null; - toolType: string | null; - summary: string | null; - lastOutputPreview: string | null; - transcriptPath: string | null; - resumeCommand: string | null; - status: string; - tracked: number; - startedAt: string; - endedAt: string | null; - exitCode: number | null; - filesChanged: number | null; - insertions: number | null; - deletions: number | null; - touchedFilesJson: string | null; - failureLinesJson: string | null; - }>( - ` - select - s.id as id, - s.title as title, - s.goal as goal, - s.tool_type as toolType, - s.summary as summary, - s.last_output_preview as lastOutputPreview, - s.transcript_path as transcriptPath, - s.resume_command as resumeCommand, - s.status as status, - s.tracked as tracked, - s.started_at as startedAt, - s.ended_at as endedAt, - s.exit_code as exitCode, - d.files_changed as filesChanged, - d.insertions as insertions, - d.deletions as deletions, - d.touched_files_json as touchedFilesJson, - d.failure_lines_json as failureLinesJson - from terminal_sessions s - left join session_deltas d on d.session_id = s.id - where s.lane_id = ? - order by s.started_at desc - limit 30 - `, - [laneId] - ); - - const sessionsTotal = Number( - db.get<{ count: number }>("select count(1) as count from terminal_sessions where lane_id = ?", [laneId])?.count ?? 0 - ); - const sessionsRunning = Number( - db.get<{ count: number }>( - "select count(1) as count from terminal_sessions where lane_id = ? and status = 'running' and pty_id is not null", - [laneId] - )?.count ?? 0 - ); - - const transcriptTailCache = new Map(); - const getTranscriptTail = async (transcriptPath: string | null): Promise => { - const key = String(transcriptPath ?? "").trim(); - if (!key) return ""; - const cached = transcriptTailCache.get(key); - if (cached != null) return cached; - const tail = await sessionService.readTranscriptTail(key, 140_000); - transcriptTailCache.set(key, tail); - return tail; - }; - - const latestTest = db.get<{ - run_id: string; - suite_id: string; - suite_name: string | null; - command_json: string | null; - status: TestRunStatus; - duration_ms: number | null; - ended_at: string | null; - }>( - ` - select - r.id as run_id, - r.suite_key as suite_id, - s.name as suite_name, - s.command_json as command_json, - r.status as status, - r.duration_ms as duration_ms, - r.ended_at as ended_at - from test_runs r - left join test_suites s on s.project_id = r.project_id and s.id = r.suite_key - where r.project_id = ? - and r.lane_id = ? - order by started_at desc - limit 1 - `, - [projectId, laneId] - ); - - const validationLines: string[] = []; - if (latestTest) { - const suiteLabel = (latestTest.suite_name ?? latestTest.suite_id).trim(); - validationLines.push( - `Tests: ${statusFromCode(latestTest.status)} (suite=${suiteLabel}, duration=${latestTest.duration_ms ?? 0}ms)` - ); - if (latestTest.command_json) { - try { - const command = JSON.parse(latestTest.command_json) as unknown; - validationLines.push(`Tests command: ${formatCommand(command)}`); - } catch { - // ignore - } - } - } else { - const latestEnded = recentSessions.find((s) => Boolean(s.endedAt)); - const transcriptTail = latestEnded ? await getTranscriptTail(latestEnded.transcriptPath) : ""; - const inferred = inferTestOutcomeFromText(transcriptTail); - if (inferred) { - validationLines.push(`Tests: ${inferred.status === "pass" ? "PASS" : "FAIL"} (inferred from terminal output)`); - } else { - validationLines.push("Tests: NOT RUN"); - } - } - - const lintSession = recentSessions.find((s) => { - const haystack = `${s.summary ?? ""} ${(s.goal ?? "")} ${s.title}`.toLowerCase(); - return haystack.includes("lint"); - }); - if (lintSession && lintSession.endedAt) { - const lintStatus = - lintSession.exitCode == null ? "ENDED" : lintSession.exitCode === 0 ? "PASS" : `FAIL (exit ${lintSession.exitCode})`; - validationLines.push(`Lint: ${lintStatus}`); - } else { - validationLines.push("Lint: NOT RUN"); - } - - type FileDelta = { insertions: number | null; deletions: number | null }; - const deltas = new Map(); - - const addDelta = (filePath: string, insRaw: string, delRaw: string) => { - const file = filePath.trim(); - if (!file) return; - const ins = insRaw === "-" ? null : Number(insRaw); - const del = delRaw === "-" ? null : Number(delRaw); - const prev = deltas.get(file); - - const next: FileDelta = { - insertions: Number.isFinite(ins as number) ? (ins as number) : ins, - deletions: Number.isFinite(del as number) ? (del as number) : del - }; - - if (!prev) { - deltas.set(file, next); - return; - } - - // Sum numeric changes; if either side is binary/unknown (null), preserve null. - deltas.set(file, { - insertions: prev.insertions == null || next.insertions == null ? null : prev.insertions + next.insertions, - deletions: prev.deletions == null || next.deletions == null ? null : prev.deletions + next.deletions - }); - }; - - const addNumstat = (stdout: string) => { - for (const line of stdout.split("\n").map((l) => l.trim()).filter(Boolean)) { - const parts = line.split("\t"); - if (parts.length < 3) continue; - const insRaw = parts[0] ?? "0"; - const delRaw = parts[1] ?? "0"; - const filePath = parts.slice(2).join("\t").trim(); - addDelta(filePath, insRaw, delRaw); - } - }; - - const mergeBaseSha = await (async (): Promise => { - const headRef = headSha ?? "HEAD"; - const baseRef = lane.baseRef?.trim() || "HEAD"; - const res = await runGit(["merge-base", headRef, baseRef], { cwd: projectRoot, timeoutMs: 12_000 }); - if (res.exitCode !== 0) return null; - const sha = res.stdout.trim(); - return sha.length ? sha : null; - })(); - - if (mergeBaseSha && (headSha ?? "HEAD") !== mergeBaseSha) { - const diff = await runGit(["diff", "--numstat", `${mergeBaseSha}..${headSha ?? "HEAD"}`], { cwd: projectRoot, timeoutMs: 20_000 }); - if (diff.exitCode === 0) addNumstat(diff.stdout); - } - - // Add unstaged + staged separately to avoid double-counting (git diff HEAD includes both). - const unstaged = await runGit(["diff", "--numstat"], { cwd: worktreePath, timeoutMs: 20_000 }); - if (unstaged.exitCode === 0) addNumstat(unstaged.stdout); - const staged = await runGit(["diff", "--numstat", "--cached"], { cwd: worktreePath, timeoutMs: 20_000 }); - if (staged.exitCode === 0) addNumstat(staged.stdout); - - const statusRes = await runGit(["status", "--porcelain=v1"], { cwd: worktreePath, timeoutMs: 8_000 }); - if (statusRes.exitCode === 0) { - const statusLines = statusRes.stdout.split("\n").map(l => l.trimEnd()).filter(Boolean); - const newUntrackedPaths: string[] = []; - - for (const line of statusLines) { - const statusCode = line.slice(0, 2); - const raw = line.slice(2).trim(); - const arrow = raw.indexOf("->"); - const rel = arrow >= 0 ? raw.slice(arrow + 2).trim() : raw; - if (!rel) continue; - - if (!deltas.has(rel)) { - if (statusCode === "??") { - newUntrackedPaths.push(rel); - } else { - deltas.set(rel, { insertions: 0, deletions: 0 }); - } - } - } - - // Count lines for untracked new files so they don't report 0/0 - for (const rel of newUntrackedPaths) { - try { - const fullPath = path.join(worktreePath, rel); - const content = await fs.promises.readFile(fullPath, "utf-8"); - const lineCount = content.split("\n").length; - deltas.set(rel, { insertions: lineCount, deletions: 0 }); - } catch { - deltas.set(rel, { insertions: 0, deletions: 0 }); - } - } - } - - if (!deltas.size && latestDelta?.touchedFiles?.length) { - for (const rel of latestDelta.touchedFiles.slice(0, 120)) { - if (!deltas.has(rel)) deltas.set(rel, { insertions: 0, deletions: 0 }); - } - } - - const whatChangedLines = (() => { - const files = [...deltas.keys()]; - if (!files.length) return []; - const byModule = new Map(); - for (const file of files) { - const module = moduleFromPath(file); - const list = byModule.get(module) ?? []; - list.push(file); - byModule.set(module, list); - } - const entries = [...byModule.entries()] - .map(([module, files]) => ({ module, files: files.sort(), count: files.length })) - .sort((a, b) => b.count - a.count || a.module.localeCompare(b.module)); - return entries.slice(0, 12).map((entry) => { - const examples = entry.files.slice(0, 3).join(", "); - const suffix = entry.files.length > 3 ? `, +${entry.files.length - 3} more` : ""; - return `${entry.module}: ${entry.count} files (${examples}${suffix})`; - }); - })(); - - const inferredWhyLines = await (async (): Promise => { - if (!mergeBaseSha) return []; - const res = await runGit(["log", "--oneline", `${mergeBaseSha}..${headSha ?? "HEAD"}`, "-n", "15"], { - cwd: projectRoot, - timeoutMs: 12_000 - }); - if (res.exitCode !== 0) return []; - return res.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - })(); - - const keyFiles = (() => { - const scored = [...deltas.entries()].map(([file, delta]) => { - const magnitude = - delta.insertions == null || delta.deletions == null ? Number.MAX_SAFE_INTEGER : delta.insertions + delta.deletions; - return { file, insertions: delta.insertions, deletions: delta.deletions, magnitude }; - }); - return scored - .sort((a, b) => b.magnitude - a.magnitude || a.file.localeCompare(b.file)) - .slice(0, 25) - .map(({ magnitude: _magnitude, ...rest }) => rest); - })(); - - const errors = (() => { - const raw = latestDelta?.failureLines ?? []; - const out: string[] = []; - const seen = new Set(); - for (const entry of raw) { - const clean = stripAnsi(entry).trim().replace(/\s+/g, " "); - if (!clean) continue; - // Filter to the current detection heuristic so stale session_deltas rows don't spam packs. - if (!/\b(error|failed|exception|fatal|traceback)\b/i.test(clean)) continue; - const clipped = clean.length > 220 ? `${clean.slice(0, 219)}…` : clean; - if (seen.has(clipped)) continue; - seen.add(clipped); - out.push(clipped); - } - return out; - })(); - - const sessionsDetailed = recentSessions.slice(0, 30).map((session) => { - const tool = humanToolLabel(session.toolType); - const goal = (session.goal ?? "").trim() || session.title; - const result = - session.endedAt == null - ? "running" - : session.exitCode == null - ? "ended" - : session.exitCode === 0 - ? "ok" - : `exit ${session.exitCode}`; - const delta = - session.filesChanged != null ? `+${session.insertions ?? 0}/-${session.deletions ?? 0}` : ""; - const prompt = (session.resumeCommand ?? "").trim(); - const touchedFiles = safeJsonParseArray(session.touchedFilesJson); - const failureLines = safeJsonParseArray(session.failureLinesJson); - const commands: string[] = []; - if (session.title && session.title !== goal) { - commands.push(session.title); - } - return { - when: isoTime(session.startedAt), - tool, - goal, - result, - delta, - prompt, - commands, - filesTouched: touchedFiles.slice(0, 20), - errors: failureLines.slice(0, 10) - }; - }); - - const nextSteps = (() => { - const items: string[] = []; - const intentSet = userIntent.trim().length && userIntent.trim() !== "Intent not set — click to add."; - if (!intentSet) items.push("Set lane intent (Why section)."); - if (lane.status.dirty) items.push("Working tree is dirty; consider committing or stashing before switching lanes."); - if (lane.status.behind > 0) items.push(`Lane is behind base by ${lane.status.behind} commits; consider syncing/rebasing.`); - if (errors.length) items.push("Errors detected in the latest session output; review Errors & Issues."); - if (latestTest && latestTest.status === "failed") items.push("Latest test run failed; fix failures before merging."); - if (sessionsRunning > 0) items.push(`${sessionsRunning} terminal session(s) currently running.`); - - const latestFailedSession = recentSessions.find((s) => s.endedAt && (s.exitCode ?? 0) !== 0); - if (latestFailedSession?.summary) items.push(`Recent failure: ${latestFailedSession.summary}`); - - return items; - })(); - - const requiredMerges = parentLane ? [parentLane.id] : []; - const conflictState = deriveConflictStateForLane(laneId); - const dependencyState: PackDependencyStateV1 = { - requiredMerges, - blockedByLanes: requiredMerges, - mergeReadiness: computeMergeReadiness({ - requiredMerges, - behindCount: lane.status.behind, - conflictStatus: (conflictState?.status ?? null) as ConflictStatusValue | null - }) - }; - - const graph = buildGraphEnvelope( - [ - { - relationType: "depends_on", - targetPackKey: "project", - targetPackType: "project", - rationale: "Lane context depends on project baseline." - }, - ...(parentLane - ? ([ - { - relationType: "blocked_by", - targetPackKey: `lane:${parentLane.id}`, - targetPackType: "lane", - targetLaneId: parentLane.id, - targetBranch: parentLane.branchRef, - rationale: "Lane is stacked on parent lane." - }, - { - relationType: "merges_into", - targetPackKey: `lane:${parentLane.id}`, - targetPackType: "lane", - targetLaneId: parentLane.id, - targetBranch: parentLane.branchRef, - rationale: "Stacked lane merges into parent lane first." - } - ] satisfies PackRelation[]) - : []) - ] satisfies PackRelation[] - ); - - const body = renderLanePackMarkdown({ - packKey: `lane:${laneId}`, - projectId, - laneId, - laneName: lane.name, - branchRef: lane.branchRef, - baseRef: lane.baseRef, - headSha, - dirty: lane.status.dirty, - ahead: lane.status.ahead, - behind: lane.status.behind, - parentName: parentLane?.name ?? (primaryLane && lane.laneType !== "primary" ? `${primaryLane.name} (primary)` : null), - deterministicUpdatedAt, - trigger: reason, - providerMode, - graph, - dependencyState, - conflictState, - whatChangedLines, - inferredWhyLines, - userIntentMarkers: { start: ADE_INTENT_START, end: ADE_INTENT_END }, - userIntent, - taskSpecMarkers: { start: ADE_TASK_SPEC_START, end: ADE_TASK_SPEC_END }, - taskSpec, - validationLines, - keyFiles, - errors, - sessionsDetailed, - sessionsTotal: Number.isFinite(sessionsTotal) ? sessionsTotal : 0, - sessionsRunning: Number.isFinite(sessionsRunning) ? sessionsRunning : 0, - nextSteps, - userTodosMarkers: { start: ADE_TODOS_START, end: ADE_TODOS_END }, - userTodos, - laneDescription: lane.description ?? "" - }); - - return { body, lastHeadSha: headSha }; - }; - - const getPackIndexRow = (packKey: string): { - pack_type: PackType; - lane_id: string | null; - pack_path: string; - deterministic_updated_at: string | null; - narrative_updated_at: string | null; - last_head_sha: string | null; - metadata_json: string | null; - } | null => { - return db.get<{ - pack_type: PackType; - lane_id: string | null; - pack_path: string; - deterministic_updated_at: string | null; - narrative_updated_at: string | null; - last_head_sha: string | null; - metadata_json: string | null; - }>( - ` - select - pack_type, - lane_id, - pack_path, - deterministic_updated_at, - narrative_updated_at, - last_head_sha, - metadata_json - from packs_index - where pack_key = ? - and project_id = ? - limit 1 - `, - [packKey, projectId] - ); - }; - - const getPackSummaryForKey = (packKey: string, fallback: { packType: PackType; packPath: string }): PackSummary => { - const row = getPackIndexRow(packKey); - const effectiveRow = row ?? { - pack_type: fallback.packType, - lane_id: null, - pack_path: fallback.packPath, - deterministic_updated_at: null, - narrative_updated_at: null, - last_head_sha: null, - metadata_json: null - }; - const version = readCurrentPackVersion(packKey); - return toPackSummaryFromRow({ packKey, row: effectiveRow, version }); - }; - - const buildLiveLanePackSummary = async (args: { - laneId: string; - reason: string; - }): Promise => { - const storedPack = getPackSummaryForKey(`lane:${args.laneId}`, { - packType: "lane", - packPath: getLanePackPath(args.laneId) - }); - const deterministicUpdatedAt = nowIso(); - const latestDelta = sessionDeltaService.listRecentLaneSessionDeltas(args.laneId, 1)[0] ?? null; - const { body, lastHeadSha } = await buildLanePackBody({ - laneId: args.laneId, - reason: args.reason, - latestDelta, - deterministicUpdatedAt - }); - return buildLivePackSummary({ - storedPack, - body, - deterministicUpdatedAt, - lastHeadSha - }); - }; - - const buildLiveProjectPackSummary = async (args: { - reason: string; - sourceLaneId?: string; - }): Promise => { - const storedPack = getPackSummaryForKey("project", { - packType: "project", - packPath: projectPackPath - }); - const deterministicUpdatedAt = nowIso(); - const body = await buildProjectPackBody({ - reason: args.reason, - deterministicUpdatedAt, - sourceLaneId: args.sourceLaneId - }); - return buildLivePackSummary({ - storedPack, - body, - deterministicUpdatedAt - }); - }; - - const buildLiveConflictPackSummary = async (args: { - laneId: string; - peerLaneId: string | null; - reason: string; - }): Promise => { - const lane = laneService.getLaneBaseAndBranch(args.laneId); - const peerKey = args.peerLaneId ?? lane.baseRef; - const storedPack = getPackSummaryForKey(`conflict:${args.laneId}:${peerKey}`, { - packType: "conflict", - packPath: getConflictPackPath(args.laneId, peerKey) - }); - const deterministicUpdatedAt = nowIso(); - const { body, lastHeadSha } = await buildConflictPackBody({ - laneId: args.laneId, - peerLaneId: args.peerLaneId, - reason: args.reason, - deterministicUpdatedAt - }); - return buildLivePackSummary({ - storedPack, - body, - deterministicUpdatedAt, - lastHeadSha - }); - }; - - const buildLivePlanPackSummary = async (args: { - laneId: string; - reason: string; - }): Promise => { - const storedPack = getPackSummaryForKey(`plan:${args.laneId}`, { - packType: "plan", - packPath: getPlanPackPath(args.laneId) - }); - const deterministicUpdatedAt = nowIso(); - const { body, headSha } = await buildPlanPackBody({ - laneId: args.laneId, - reason: args.reason, - deterministicUpdatedAt - }); - return buildLivePackSummary({ - storedPack, - body, - deterministicUpdatedAt, - lastHeadSha: headSha - }); - }; - - const buildLiveFeaturePackSummary = async (args: { - featureKey: string; - reason: string; - }): Promise => { - const storedPack = getPackSummaryForKey(`feature:${args.featureKey}`, { - packType: "feature", - packPath: getFeaturePackPath(args.featureKey) - }); - const deterministicUpdatedAt = nowIso(); - const { body } = await buildFeaturePackBody({ - featureKey: args.featureKey, - reason: args.reason, - deterministicUpdatedAt - }); - return buildLivePackSummary({ - storedPack, - body, - deterministicUpdatedAt - }); - }; - - const buildLiveMissionPackSummary = async (args: { - missionId: string; - reason: string; - }): Promise => { - const storedPack = getPackSummaryForKey(`mission:${args.missionId}`, { - packType: "mission", - packPath: getMissionPackPath(args.missionId) - }); - const deterministicUpdatedAt = nowIso(); - const { body } = await buildMissionPackBody({ - missionId: args.missionId, - reason: args.reason, - deterministicUpdatedAt, - runId: null - }); - return buildLivePackSummary({ - storedPack, - body, - deterministicUpdatedAt - }); - }; - - // ── Mission/Plan/Feature builder wrappers ──────────────────────────────────── - - const missionPackBuilderDeps: MissionPackBuilderDeps = { - db, - logger, - projectRoot, - projectId, - packsDir, - laneService, - projectConfigService, - getLanePackBody: async (laneId: string) => { - const pack = await buildLiveLanePackSummary({ - laneId, - reason: "context_export_dependency" - }); - return pack.body; - }, - readConflictPredictionPack, - getHeadSha, - getPackIndexRow - }; - - const buildFeaturePackBody = (args: { featureKey: string; reason: string; deterministicUpdatedAt: string }) => - buildFeaturePackBodyImpl(missionPackBuilderDeps, args); - const buildPlanPackBody = (args: { laneId: string; reason: string; deterministicUpdatedAt: string }) => - buildPlanPackBodyImpl(missionPackBuilderDeps, args); - const buildMissionPackBody = (args: { missionId: string; reason: string; deterministicUpdatedAt: string; runId?: string | null }) => - buildMissionPackBodyImpl(missionPackBuilderDeps, args); - const buildConflictPackBody = (args: { laneId: string; peerLaneId: string | null; reason: string; deterministicUpdatedAt: string }) => - buildConflictPackBodyImpl(conflictPackBuilderDeps, args); - - const buildLivePackSummary = (args: { - storedPack: PackSummary; - body: string; - deterministicUpdatedAt: string | null; - narrativeUpdatedAt?: string | null; - lastHeadSha?: string | null; - metadata?: Record | null; - }): PackSummary => { - const contentHash = sha256(args.body); - const reuseStoredVersion = - typeof args.storedPack.contentHash === "string" && - args.storedPack.contentHash === contentHash && - typeof args.storedPack.versionId === "string" && - args.storedPack.versionId.trim().length > 0; - const versionId = reuseStoredVersion - ? args.storedPack.versionId ?? null - : `live:${args.storedPack.packKey}:${contentHash.slice(0, 16)}`; - const versionNumber = reuseStoredVersion ? args.storedPack.versionNumber ?? null : null; - - return { - ...args.storedPack, - exists: args.body.trim().length > 0 || args.storedPack.exists, - deterministicUpdatedAt: args.deterministicUpdatedAt, - narrativeUpdatedAt: args.narrativeUpdatedAt ?? args.storedPack.narrativeUpdatedAt ?? null, - lastHeadSha: args.lastHeadSha ?? args.storedPack.lastHeadSha ?? null, - versionId, - versionNumber, - contentHash, - metadata: args.metadata ?? args.storedPack.metadata ?? null, - body: args.body - }; - }; - - const buildBasicExport = (args: { - packKey: string; - packType: "feature" | "plan" | "mission"; - level: ContextExportLevel; - pack: PackSummary; - }): PackExport => { - const header = { - schema: CONTEXT_HEADER_SCHEMA_V1, - contractVersion: CONTEXT_CONTRACT_VERSION, - projectId, - packKey: args.packKey, - packType: args.packType, - exportLevel: args.level, - deterministicUpdatedAt: args.pack.deterministicUpdatedAt ?? null, - narrativeUpdatedAt: args.pack.narrativeUpdatedAt ?? null, - versionId: args.pack.versionId ?? null, - versionNumber: args.pack.versionNumber ?? null, - contentHash: args.pack.contentHash ?? null - } satisfies ContextHeaderV1; - const content = args.pack.body; - const approxTokens = Math.ceil(content.length / 4); - const maxTokens = args.level === "lite" ? 30_000 : args.level === "standard" ? 60_000 : 120_000; - const truncated = approxTokens > maxTokens; - const finalContent = truncated ? content.slice(0, maxTokens * 4) : content; - return { - packKey: args.packKey, - packType: args.packType, - level: args.level, - header, - content: finalContent, - approxTokens: Math.ceil(finalContent.length / 4), - maxTokens, - truncated, - warnings: truncated ? [`${args.packType[0]!.toUpperCase()}${args.packType.slice(1)} pack content was truncated to fit token budget.`] : [] - }; - }; - - return { - getProjectPack(): PackSummary { - const row = db.get<{ - pack_type: PackType; - pack_path: string; - deterministic_updated_at: string | null; - narrative_updated_at: string | null; - last_head_sha: string | null; - metadata_json: string | null; - }>( - ` - select - pack_type, - pack_path, - deterministic_updated_at, - narrative_updated_at, - last_head_sha, - metadata_json - from packs_index - where pack_key = 'project' - and project_id = ? - limit 1 - `, - [projectId] - ); - - const version = readCurrentPackVersion("project"); - if (row) return toPackSummaryFromRow({ packKey: "project", row, version }); - - const body = readFileIfExists(projectPackPath); - const exists = fs.existsSync(projectPackPath); - return { - packKey: "project", - packType: "project", - path: projectPackPath, - exists, - deterministicUpdatedAt: null, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: version?.versionId ?? null, - versionNumber: version?.versionNumber ?? null, - contentHash: version?.contentHash ?? null, - metadata: null, - body - }; - }, - - getLanePack(laneId: string): PackSummary { - const row = db.get<{ - pack_type: PackType; - pack_path: string; - deterministic_updated_at: string | null; - narrative_updated_at: string | null; - last_head_sha: string | null; - metadata_json: string | null; - }>( - ` - select - pack_type, - pack_path, - deterministic_updated_at, - narrative_updated_at, - last_head_sha, - metadata_json - from packs_index - where pack_key = ? - and project_id = ? - limit 1 - `, - [`lane:${laneId}`, projectId] - ); - - const packKey = `lane:${laneId}`; - const version = readCurrentPackVersion(packKey); - if (row) return toPackSummaryFromRow({ packKey, row, version }); - - const lanePackPath = getLanePackPath(laneId); - const body = readFileIfExists(lanePackPath); - const exists = fs.existsSync(lanePackPath); - return { - packKey, - packType: "lane", - path: lanePackPath, - exists, - deterministicUpdatedAt: null, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: version?.versionId ?? null, - versionNumber: version?.versionNumber ?? null, - contentHash: version?.contentHash ?? null, - metadata: null, - body - }; - }, - - getFeaturePack(featureKey: string): PackSummary { - const key = featureKey.trim(); - if (!key) throw new Error("featureKey is required"); - const packKey = `feature:${key}`; - return getPackSummaryForKey(packKey, { packType: "feature", packPath: getFeaturePackPath(key) }); - }, - - getConflictPack(args: { laneId: string; peerLaneId?: string | null }): PackSummary { - const laneId = args.laneId.trim(); - if (!laneId) throw new Error("laneId is required"); - const peer = args.peerLaneId?.trim() || null; - const lane = laneService.getLaneBaseAndBranch(laneId); - const peerKey = peer ?? lane.baseRef; - const packKey = `conflict:${laneId}:${peerKey}`; - return getPackSummaryForKey(packKey, { packType: "conflict", packPath: getConflictPackPath(laneId, peerKey) }); - }, - - getPlanPack(laneId: string): PackSummary { - const id = laneId.trim(); - if (!id) throw new Error("laneId is required"); - const packKey = `plan:${id}`; - return getPackSummaryForKey(packKey, { packType: "plan", packPath: getPlanPackPath(id) }); - }, - - getMissionPack(missionId: string): PackSummary { - const id = missionId.trim(); - if (!id) throw new Error("missionId is required"); - const packKey = `mission:${id}`; - return getPackSummaryForKey(packKey, { packType: "mission", packPath: getMissionPackPath(id) }); - }, - - async refreshLanePack(args: { laneId: string; reason: string; sessionId?: string }): Promise { - const op = operationService.start({ - laneId: args.laneId, - kind: "pack_update_lane", - metadata: { - reason: args.reason, - sessionId: args.sessionId ?? null - } - }); - - try { - const latestDelta = args.sessionId - ? await sessionDeltaService.computeSessionDelta(args.sessionId) - : sessionDeltaService.listRecentLaneSessionDeltas(args.laneId, 1)[0] ?? null; - const deterministicUpdatedAt = nowIso(); - const { body, lastHeadSha } = await buildLanePackBody({ - laneId: args.laneId, - reason: args.reason, - latestDelta, - deterministicUpdatedAt - }); - - const packKey = `lane:${args.laneId}`; - const packPath = getLanePackPath(args.laneId); - const summary = persistPackRefresh({ - packKey, - packType: "lane", - packPath, - laneId: args.laneId, - body, - deterministicUpdatedAt, - narrativeUpdatedAt: null, - lastHeadSha, - metadata: { - reason: args.reason, - sessionId: args.sessionId ?? null, - latestDeltaSessionId: latestDelta?.sessionId ?? null, - operationId: op.operationId - }, - eventType: "refresh_triggered", - eventPayload: { - operationId: op.operationId, - trigger: args.reason, - laneId: args.laneId, - sessionId: args.sessionId ?? null - } - }); - - if (args.sessionId && latestDelta) { - const checkpointSha = - lastHeadSha ?? - latestDelta.headShaEnd ?? - latestDelta.headShaStart ?? - null; - if (checkpointSha) { - recordCheckpointFromDelta({ - laneId: args.laneId, - sessionId: args.sessionId, - sha: checkpointSha, - delta: latestDelta - }); - } - } - - operationService.finish({ - operationId: op.operationId, - status: "succeeded", - postHeadSha: lastHeadSha, - metadataPatch: { - packPath, - deterministicUpdatedAt, - latestDeltaSessionId: latestDelta?.sessionId ?? null, - versionId: summary.versionId ?? null, - versionNumber: summary.versionNumber ?? null - } - }); - return summary; - } catch (error) { - operationService.finish({ - operationId: op.operationId, - status: "failed", - metadataPatch: { - error: error instanceof Error ? error.message : String(error) - } - }); - throw error; - } - }, - - async refreshProjectPack(args: { reason: string; laneId?: string }): Promise { - const op = operationService.start({ - laneId: args.laneId ?? null, - kind: "pack_update_project", - metadata: { - reason: args.reason, - sourceLaneId: args.laneId ?? null - } - }); - - try { - const deterministicUpdatedAt = nowIso(); - const body = await buildProjectPackBody({ - reason: args.reason, - deterministicUpdatedAt, - sourceLaneId: args.laneId - }); - - const packKey = "project"; - const summary = persistPackRefresh({ - packKey, - packType: "project", - packPath: projectPackPath, - laneId: null, - body, - deterministicUpdatedAt, - narrativeUpdatedAt: null, - lastHeadSha: null, - metadata: { - ...readContextDocMeta(), - reason: args.reason, - sourceLaneId: args.laneId ?? null, - operationId: op.operationId - }, - eventType: "refresh_triggered", - eventPayload: { - operationId: op.operationId, - trigger: args.reason, - laneId: args.laneId ?? null - } - }); - - operationService.finish({ - operationId: op.operationId, - status: "succeeded", - metadataPatch: { - packPath: projectPackPath, - deterministicUpdatedAt, - versionId: summary.versionId ?? null, - versionNumber: summary.versionNumber ?? null - } - }); - return summary; - } catch (error) { - operationService.finish({ - operationId: op.operationId, - status: "failed", - metadataPatch: { - error: error instanceof Error ? error.message : String(error) - } - }); - throw error; - } - }, - - async refreshMissionPack(args: { missionId: string; reason: string; runId?: string | null }): Promise { - const missionId = args.missionId.trim(); - if (!missionId) throw new Error("missionId is required"); - const packKey = `mission:${missionId}`; - const deterministicUpdatedAt = nowIso(); - const built = await buildMissionPackBody({ - missionId, - reason: args.reason, - deterministicUpdatedAt, - runId: args.runId ?? null - }); - const packPath = getMissionPackPath(missionId); - return persistPackRefresh({ - packKey, - packType: "mission", - packPath, - laneId: built.laneId, - body: built.body, - deterministicUpdatedAt, - narrativeUpdatedAt: null, - lastHeadSha: null, - metadata: { - reason: args.reason, - missionId, - runId: args.runId ?? null - }, - eventType: "refresh_triggered", - eventPayload: { - trigger: args.reason, - missionId, - runId: args.runId ?? null - } - }); - }, - - getVersion(versionId: string): PackVersion { - const id = versionId.trim(); - if (!id) throw new Error("versionId is required"); - const row = db.get<{ - id: string; - pack_key: string; - version_number: number; - content_hash: string; - rendered_path: string; - created_at: string; - }>( - ` - select id, pack_key, version_number, content_hash, rendered_path, created_at - from pack_versions - where project_id = ? - and id = ? - limit 1 - `, - [projectId, id] - ); - if (!row) throw new Error(`Pack version not found: ${id}`); - const packType = getPackIndexRow(row.pack_key)?.pack_type ?? inferPackTypeFromKey(row.pack_key); - return { - id: row.id, - packKey: row.pack_key, - packType, - versionNumber: Number(row.version_number ?? 0), - contentHash: String(row.content_hash ?? ""), - renderedPath: row.rendered_path, - body: readFileIfExists(row.rendered_path), - createdAt: row.created_at - }; - }, - - listEventsSince(args: ListPackEventsSinceArgs): PackEvent[] { - const packKey = args.packKey.trim(); - if (!packKey) throw new Error("packKey is required"); - const sinceIso = args.sinceIso.trim(); - if (!sinceIso) throw new Error("sinceIso is required"); - const limit = typeof args.limit === "number" ? Math.max(1, Math.min(500, Math.floor(args.limit))) : 200; - - const rows = db.all<{ - id: string; - pack_key: string; - event_type: string; - payload_json: string | null; - created_at: string; - }>( - ` - select id, pack_key, event_type, payload_json, created_at - from pack_events - where project_id = ? - and pack_key = ? - and created_at > ? - order by created_at asc - limit ? - `, - [projectId, packKey, sinceIso, limit] - ); - - return rows.map((row) => - ensureEventMeta({ - id: row.id, - packKey: row.pack_key, - eventType: row.event_type, - payload: (() => { - try { - return row.payload_json ? (JSON.parse(row.payload_json) as Record) : {}; - } catch { - return {}; - } - })(), - createdAt: row.created_at - }) - ); - }, - - getHeadVersion(args: { packKey: string }): PackHeadVersion { - const packKey = args.packKey.trim(); - if (!packKey) throw new Error("packKey is required"); - const packType = getPackIndexRow(packKey)?.pack_type ?? inferPackTypeFromKey(packKey); - const row = db.get<{ - id: string; - version_number: number; - content_hash: string; - updated_at: string; - }>( - ` - select v.id as id, - v.version_number as version_number, - v.content_hash as content_hash, - h.updated_at as updated_at - from pack_heads h - join pack_versions v on v.id = h.current_version_id and v.project_id = h.project_id - where h.project_id = ? - and h.pack_key = ? - limit 1 - `, - [projectId, packKey] - ); - - return { - packKey, - packType, - versionId: row?.id ?? null, - versionNumber: row ? Number(row.version_number ?? 0) : null, - contentHash: row?.content_hash != null ? String(row.content_hash) : null, - updatedAt: row?.updated_at ?? null - }; - }, - - async getDeltaDigest(args: PackDeltaDigestArgs): Promise { - const packKey = (args.packKey ?? "").trim(); - if (!packKey) throw new Error("packKey is required"); - - const minimum = args.minimumImportance ?? "medium"; - const limit = typeof args.limit === "number" ? Math.max(10, Math.min(500, Math.floor(args.limit))) : 200; - - const sinceVersionId = typeof args.sinceVersionId === "string" ? args.sinceVersionId.trim() : ""; - const sinceTimestamp = typeof args.sinceTimestamp === "string" ? args.sinceTimestamp.trim() : ""; - if (!sinceVersionId && !sinceTimestamp) { - throw new Error("sinceVersionId or sinceTimestamp is required"); - } - - let baselineVersion: PackVersion | null = null; - let baselineCreatedAt: string | null = null; - let baselineVersionId: string | null = null; - let baselineVersionNumber: number | null = null; - let sinceIso = sinceTimestamp; - - if (sinceVersionId) { - const v = this.getVersion(sinceVersionId); - baselineVersion = v; - baselineCreatedAt = v.createdAt; - baselineVersionId = v.id; - baselineVersionNumber = v.versionNumber; - sinceIso = v.createdAt; - } else { - const parsed = Date.parse(sinceTimestamp); - if (!Number.isFinite(parsed)) throw new Error("Invalid sinceTimestamp"); - const baseline = findBaselineVersionAtOrBefore({ packKey, sinceIso: sinceTimestamp }); - if (baseline?.id) { - const v = this.getVersion(baseline.id); - baselineVersion = v; - baselineCreatedAt = v.createdAt; - baselineVersionId = v.id; - baselineVersionNumber = v.versionNumber; - sinceIso = v.createdAt; - } - } - - const newVersion = this.getHeadVersion({ packKey }); - const packType: PackType = newVersion.packType; - const afterBody = newVersion.versionId ? this.getVersion(newVersion.versionId).body : ""; - const beforeBody = baselineVersion?.body ?? null; - - const changedSections = computeSectionChanges({ - before: beforeBody, - after: afterBody, - locators: getDefaultSectionLocators(packType) - }); - - const eventsRaw = this.listEventsSince({ packKey, sinceIso, limit }); - const highImpactEvents = eventsRaw.filter((event) => { - const payload = (event.payload ?? {}) as Record; - return importanceRank(payload.importance) >= importanceRank(minimum); - }); - - const conflictState = (() => { - if (!packKey.startsWith("lane:")) return null; - const laneId = packKey.slice("lane:".length); - return deriveConflictStateForLane(laneId); - })(); - - const blockers: Array<{ kind: string; summary: string; entityIds?: string[] }> = []; - if (packKey.startsWith("lane:")) { - const laneId = packKey.slice("lane:".length); - const row = db.get<{ parent_lane_id: string | null }>( - "select parent_lane_id from lanes where id = ? and project_id = ? limit 1", - [laneId, projectId] - ); - const parentLaneId = row?.parent_lane_id ?? null; - if (parentLaneId) { - blockers.push({ - kind: "merge", - summary: `Blocked by parent lane ${parentLaneId} (stacked lane).`, - entityIds: [laneId, parentLaneId] - }); - } - } - if (conflictState?.status === "conflict-active" || conflictState?.status === "conflict-predicted") { - blockers.push({ - kind: "conflict", - summary: `Conflicts: ${conflictState.status} (peerConflicts=${conflictState.peerConflictCount ?? 0}).`, - entityIds: [] - }); - } - if (conflictState?.truncated) { - blockers.push({ - kind: "conflict", - summary: `Conflict coverage is partial (strategy=${conflictState.strategy ?? "partial"}; pairs=${conflictState.pairwisePairsComputed ?? 0}/${conflictState.pairwisePairsTotal ?? 0}).`, - entityIds: [] - }); - } - - const decisionReasons: string[] = []; - let recommendedExportLevel: ContextExportLevel = "lite"; - if (changedSections.some((c) => c.sectionId === "narrative")) { - recommendedExportLevel = "deep"; - decisionReasons.push("Narrative changed; deep export includes narrative content."); - } else if (blockers.length || (conflictState?.status && conflictState.status !== "merge-ready")) { - recommendedExportLevel = "standard"; - decisionReasons.push("Blockers/conflicts present; standard export recommended."); - } else if (changedSections.length) { - recommendedExportLevel = "standard"; - decisionReasons.push("Multiple sections changed; standard export recommended."); - } else { - decisionReasons.push("No material section changes detected; lite is sufficient."); - } - - const handoffSummary = (() => { - const parts: string[] = []; - const baseLabel = - baselineVersionNumber != null && newVersion.versionNumber != null - ? `v${baselineVersionNumber} -> v${newVersion.versionNumber}` - : `since ${sinceIso}`; - parts.push(`${packKey} delta (${baseLabel}).`); - if (changedSections.length) parts.push(`Changed: ${changedSections.map((c) => c.sectionId).join(", ")}.`); - if (blockers.length) parts.push(`Blockers: ${blockers.map((b) => b.summary).join(" ")}`); - if (highImpactEvents.length) { - const top = highImpactEvents - .slice(-6) - .map((e) => `${e.eventType}${(e.payload as any)?.rationale ? ` (${String((e.payload as any).rationale)})` : ""}`); - parts.push(`Events: ${top.join("; ")}.`); - } - if (conflictState?.lastPredictedAt) parts.push(`Conflicts last predicted at: ${conflictState.lastPredictedAt}.`); - return parts.join(" "); - })(); - - const omittedSections: string[] = []; - if (eventsRaw.length >= limit) { - omittedSections.push("events:limit_cap"); - } - if (conflictState?.truncated) { - omittedSections.push("conflicts:partial_coverage"); - } - const clipReason = omittedSections.length > 0 ? "budget_clipped" : null; - - return { - packKey, - packType, - since: { - sinceVersionId: sinceVersionId || null, - sinceTimestamp: sinceTimestamp || sinceIso, - baselineVersionId, - baselineVersionNumber, - baselineCreatedAt - }, - newVersion, - changedSections, - highImpactEvents, - blockers, - conflicts: conflictState, - decisionState: { - recommendedExportLevel, - reasons: decisionReasons - }, - handoffSummary, - clipReason, - omittedSections: omittedSections.length ? omittedSections : null - }; - }, - - async getLaneExport(args: GetLaneExportArgs): Promise { - const laneId = args.laneId.trim(); - if (!laneId) throw new Error("laneId is required"); - const level = args.level; - if (level !== "lite" && level !== "standard" && level !== "deep") { - throw new Error(`Invalid export level: ${String(level)}`); - } - - const lanes = await laneService.list({ includeArchived: true }); - const lane = lanes.find((entry) => entry.id === laneId); - if (!lane) throw new Error(`Lane not found: ${laneId}`); - - const pack = await buildLiveLanePackSummary({ - laneId, - reason: "context_export" - }); - - const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; - const { apiBaseUrl, remoteProjectId } = readGatewayMeta(); - const docsMeta = readContextDocMeta(); - const conflictRiskSummaryLines = buildLaneConflictRiskSummaryLines(laneId); - - const conflictState = deriveConflictStateForLane(laneId); - const lanesById = new Map(lanes.map((l) => [l.id, l] as const)); - const lineage = computeLaneLineage({ laneId, lanesById }); - - const requiredMerges = lane.parentLaneId ? [lane.parentLaneId] : []; - const dependencyState: PackDependencyStateV1 = { - requiredMerges, - blockedByLanes: requiredMerges, - mergeReadiness: computeMergeReadiness({ - requiredMerges, - behindCount: lane.status.behind, - conflictStatus: (conflictState?.status ?? null) as ConflictStatusValue | null - }) - }; - - const predictionPack = readConflictPredictionPack(laneId); - const lastConflictRefreshAt = - asString(predictionPack?.lastRecomputedAt).trim() || asString(predictionPack?.generatedAt).trim() || null; - const lastConflictRefreshAgeMs = (() => { - if (!lastConflictRefreshAt) return null; - const ts = Date.parse(lastConflictRefreshAt); - if (!Number.isFinite(ts)) return null; - return Math.max(0, Date.now() - ts); - })(); - const ttlMs = Number((predictionPack as any)?.stalePolicy?.ttlMs ?? NaN); - const staleTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 5 * 60_000; - const predictionStale = lastConflictRefreshAgeMs != null ? lastConflictRefreshAgeMs > staleTtlMs : null; - const staleReason = - predictionStale && lastConflictRefreshAgeMs != null - ? `lastConflictRefreshAgeMs=${lastConflictRefreshAgeMs} ttlMs=${staleTtlMs}` - : null; - - const activeConflictPackKeys = (() => { - const out: string[] = []; - if (predictionPack?.status) out.push(`conflict:${laneId}:${lane.baseRef}`); - const overlaps = Array.isArray(predictionPack?.overlaps) ? predictionPack!.overlaps! : []; - const score = (v: ConflictRiskLevel) => (v === "high" ? 3 : v === "medium" ? 2 : v === "low" ? 1 : 0); - const peers = overlaps - .filter((ov) => ov && ov.peerId != null) - .map((ov) => ({ - peerId: asString(ov.peerId).trim(), - riskLevel: normalizeRiskLevel(asString(ov.riskLevel)) ?? "none", - fileCount: Array.isArray(ov.files) ? ov.files.length : 0 - })) - .filter((ov) => ov.peerId.length) - .sort((a, b) => score(b.riskLevel) - score(a.riskLevel) || b.fileCount - a.fileCount || a.peerId.localeCompare(b.peerId)) - .slice(0, 6); - for (const peer of peers) out.push(`conflict:${laneId}:${peer.peerId}`); - return uniqueSorted(out); - })(); - - // --- Orchestrator summary --- - const orchestratorSummary: OrchestratorLaneSummaryV1 = (() => { - // Completion signal - let completionSignal: LaneCompletionSignal = "in-progress"; - if (lane.status.ahead === 0) { - completionSignal = "not-started"; - } else if (conflictState?.status === "conflict-active") { - completionSignal = "blocked"; - } else if ( - lane.status.ahead > 0 && - !lane.status.dirty && - conflictState?.status === "merge-ready" - ) { - completionSignal = "review-ready"; - } - - // Touched files — extract from Key Files table in pack body - const touchedFiles: string[] = []; - const keyFilesRe = /\|\s*`([^`]+)`/g; - let kfMatch: RegExpExecArray | null; - const bodyText = pack.body ?? ""; - const keyFilesStart = bodyText.indexOf("## Key Files"); - if (keyFilesStart !== -1) { - const keyFilesSection = bodyText.slice(keyFilesStart, bodyText.indexOf("\n## ", keyFilesStart + 1) >>> 0 || bodyText.length); - while ((kfMatch = keyFilesRe.exec(keyFilesSection)) !== null) { - const fp = kfMatch[1].trim(); - if (fp.length > 0 && !touchedFiles.includes(fp)) touchedFiles.push(fp); - } - } - - // Peer overlaps from prediction pack - const peerOverlaps = (Array.isArray(predictionPack?.overlaps) ? predictionPack!.overlaps : []) - .filter((ov: any) => ov && ov.peerId) - .slice(0, 10) - .map((ov: any) => ({ - peerId: String(ov.peerId ?? "").trim(), - files: Array.isArray(ov.files) ? ov.files.map(String).slice(0, 20) : [], - risk: (normalizeRiskLevel(String(ov.riskLevel ?? "")) ?? "none") as ConflictRiskLevel - })) - .filter((ov: { peerId: string }) => ov.peerId.length > 0); - - // Blockers - const blockers: string[] = []; - if (lane.status.dirty) blockers.push("dirty working tree"); - if (conflictState?.status === "conflict-active") blockers.push("active merge conflict"); - if (conflictState?.status === "conflict-predicted") blockers.push("predicted conflicts with peer lanes"); - if (lane.status.behind > 0) blockers.push(`behind base by ${lane.status.behind} commits`); - - return { - laneId, - completionSignal, - touchedFiles: touchedFiles.slice(0, 50), - peerOverlaps, - suggestedMergeOrder: null, - blockers - }; - })(); - - const manifest: LaneExportManifestV1 = { - schema: "ade.manifest.lane.v1", - projectId, - laneId, - laneName: lane.name, - laneType: lane.laneType, - worktreePath: lane.worktreePath, - branchRef: lane.branchRef, - baseRef: lane.baseRef, - contextFingerprint: docsMeta.contextFingerprint, - contextVersion: docsMeta.contextVersion, - lastDocsRefreshAt: docsMeta.lastDocsRefreshAt, - ...(docsMeta.docsStaleReason ? { docsStaleReason: docsMeta.docsStaleReason } : {}), - lineage, - mergeConstraints: { - requiredMerges, - blockedByLanes: requiredMerges, - mergeReadiness: dependencyState.mergeReadiness ?? "unknown" - }, - branchState: { - baseRef: lane.baseRef, - headRef: lane.branchRef, - headSha: pack.lastHeadSha ?? null, - lastPackRefreshAt: pack.deterministicUpdatedAt ?? null, - isEditProtected: lane.isEditProtected, - packStale: false - }, - conflicts: { - activeConflictPackKeys, - unresolvedPairCount: conflictState?.unresolvedPairCount ?? 0, - lastConflictRefreshAt, - lastConflictRefreshAgeMs, - ...(predictionPack?.truncated != null ? { truncated: Boolean(predictionPack.truncated) } : {}), - ...(asString(predictionPack?.strategy).trim() ? { strategy: asString(predictionPack?.strategy).trim() } : {}), - ...(Number.isFinite(Number(predictionPack?.pairwisePairsComputed)) ? { pairwisePairsComputed: Number(predictionPack?.pairwisePairsComputed) } : {}), - ...(Number.isFinite(Number(predictionPack?.pairwisePairsTotal)) ? { pairwisePairsTotal: Number(predictionPack?.pairwisePairsTotal) } : {}), - predictionStale, - predictionStalenessMs: lastConflictRefreshAgeMs, - stalePolicy: { ttlMs: staleTtlMs }, - ...(staleReason ? { staleReason } : {}), - unresolvedResolutionState: null - }, - orchestratorSummary - }; - - const graph = buildGraphEnvelope( - [ - { - relationType: "depends_on", - targetPackKey: "project", - targetPackType: "project", - rationale: "Lane export depends on project context." - }, - ...(lane.parentLaneId - ? ([ - { - relationType: "blocked_by", - targetPackKey: `lane:${lane.parentLaneId}`, - targetPackType: "lane", - targetLaneId: lane.parentLaneId, - rationale: "Stacked lane depends on parent lane landing first." - }, - { - relationType: "merges_into", - targetPackKey: `lane:${lane.parentLaneId}`, - targetPackType: "lane", - targetLaneId: lane.parentLaneId, - rationale: "Stacked lane merges into parent lane first." - } - ] satisfies PackRelation[]) - : ([ - { - relationType: "merges_into", - targetPackKey: `lane:${lineage.baseLaneId ?? laneId}`, - targetPackType: "lane", - targetLaneId: lineage.baseLaneId ?? laneId, - rationale: "Lane merges into base lane." - } - ] satisfies PackRelation[])) - ] satisfies PackRelation[] - ); - - return buildLaneExport({ - level, - projectId, - laneId, - laneName: lane.name, - branchRef: lane.branchRef, - baseRef: lane.baseRef, - headSha: pack.lastHeadSha ?? null, - pack, - providerMode, - apiBaseUrl, - remoteProjectId, - graph, - manifest, - dependencyState, - conflictState, - markers: { - taskSpecStart: ADE_TASK_SPEC_START, - taskSpecEnd: ADE_TASK_SPEC_END, - intentStart: ADE_INTENT_START, - intentEnd: ADE_INTENT_END, - todosStart: ADE_TODOS_START, - todosEnd: ADE_TODOS_END, - narrativeStart: ADE_NARRATIVE_START, - narrativeEnd: ADE_NARRATIVE_END - }, - conflictRiskSummaryLines - }); - }, - - async getProjectExport(args: GetProjectExportArgs): Promise { - const level = args.level; - if (level !== "lite" && level !== "standard" && level !== "deep") { - throw new Error(`Invalid export level: ${String(level)}`); - } - const pack = await buildLiveProjectPackSummary({ - reason: "context_export" - }); - const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; - const { apiBaseUrl, remoteProjectId } = readGatewayMeta(); - const docsMeta = readContextDocMeta(); - - const lanes = await laneService.list({ includeArchived: false }); - const lanesById = new Map(lanes.map((lane) => [lane.id, lane] as const)); - const lanesTotal = lanes.length; - const maxIncluded = level === "lite" ? 10 : level === "standard" ? 25 : 80; - const included = [...lanes] - .filter((lane) => !lane.archivedAt) - .sort((a, b) => a.stackDepth - b.stackDepth || a.name.localeCompare(b.name)) - .slice(0, maxIncluded); - - const laneEntries: ProjectManifestLaneEntryV1[] = included.map((lane) => { - const lineage = computeLaneLineage({ laneId: lane.id, lanesById }); - const requiredMerges = lane.parentLaneId ? [lane.parentLaneId] : []; - const conflictState = deriveConflictStateForLane(lane.id); - const mergeReadiness = computeMergeReadiness({ - requiredMerges, - behindCount: lane.status.behind, - conflictStatus: (conflictState?.status ?? null) as ConflictStatusValue | null - }); - - return { - laneId: lane.id, - laneName: lane.name, - laneType: lane.laneType, - branchRef: lane.branchRef, - baseRef: lane.baseRef, - worktreePath: lane.worktreePath, - isEditProtected: Boolean(lane.isEditProtected), - status: lane.status, - lineage, - mergeConstraints: { - requiredMerges, - blockedByLanes: requiredMerges, - mergeReadiness - }, - branchState: { - baseRef: lane.baseRef, - headRef: lane.branchRef, - headSha: null, - lastPackRefreshAt: null, - isEditProtected: lane.isEditProtected, - packStale: false - }, - conflictState - }; - }); - - const manifest: ProjectExportManifestV1 = { - schema: "ade.manifest.project.v1", - projectId, - generatedAt: new Date().toISOString(), - contextFingerprint: docsMeta.contextFingerprint, - contextVersion: docsMeta.contextVersion, - lastDocsRefreshAt: docsMeta.lastDocsRefreshAt, - ...(docsMeta.docsStaleReason ? { docsStaleReason: docsMeta.docsStaleReason } : {}), - lanesTotal, - lanesIncluded: included.length, - lanesOmitted: Math.max(0, lanesTotal - included.length), - lanes: laneEntries - }; - - const graph = buildGraphEnvelope( - laneEntries.map((lane) => ({ - relationType: "parent_of", - targetPackKey: `lane:${lane.laneId}`, - targetPackType: "lane", - targetLaneId: lane.laneId, - targetBranch: lane.branchRef, - rationale: "Project contains lane context." - })) satisfies PackRelation[] - ); - - return buildProjectExport({ level, projectId, pack, providerMode, apiBaseUrl, remoteProjectId, graph, manifest }); - }, - - async getConflictExport(args: GetConflictExportArgs): Promise { - const laneId = args.laneId.trim(); - if (!laneId) throw new Error("laneId is required"); - const peerLaneId = args.peerLaneId?.trim() || null; - const level = args.level; - if (level !== "lite" && level !== "standard" && level !== "deep") { - throw new Error(`Invalid export level: ${String(level)}`); - } - - const lane = laneService.getLaneBaseAndBranch(laneId); - const peerKey = peerLaneId ?? lane.baseRef; - const packKey = `conflict:${laneId}:${peerKey}`; - const peerLabel = peerLaneId ? `lane:${peerLaneId}` : `base:${lane.baseRef}`; - - const pack = await buildLiveConflictPackSummary({ - laneId, - peerLaneId, - reason: "context_export" - }); - const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; - const { apiBaseUrl, remoteProjectId } = readGatewayMeta(); - - const predictionPack = readConflictPredictionPack(laneId); - const matrix = Array.isArray(predictionPack?.matrix) ? predictionPack!.matrix! : []; - const entry = - peerLaneId == null - ? (matrix.find((m) => asString(m.laneAId).trim() === laneId && asString(m.laneBId).trim() === laneId) ?? null) - : (matrix.find((m) => { - const a = asString(m.laneAId).trim(); - const b = asString(m.laneBId).trim(); - return (a === laneId && b === peerLaneId) || (a === peerLaneId && b === laneId); - }) ?? null); - - const ttlMs = Number((predictionPack as any)?.stalePolicy?.ttlMs ?? NaN); - const staleTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 5 * 60_000; - const nowMs = Date.now(); - const predictionAt = - asString((entry as any)?.computedAt).trim() || - asString((predictionPack as any)?.predictionAt).trim() || - asString((predictionPack as any)?.status?.lastPredictedAt).trim() || - null; - const predictionAgeMs = (() => { - if (!predictionAt) return null; - const ts = Date.parse(predictionAt); - if (!Number.isFinite(ts)) return null; - return Math.max(0, nowMs - ts); - })(); - const predictionStale = predictionAgeMs != null ? predictionAgeMs > staleTtlMs : null; - const staleReason = predictionStale && predictionAgeMs != null ? `predictionAgeMs=${predictionAgeMs} ttlMs=${staleTtlMs}` : null; - - const openConflictSummaries = (() => { - const raw = Array.isArray(predictionPack?.openConflictSummaries) ? predictionPack!.openConflictSummaries! : null; - if (raw) { - return raw - .map((s) => { - const riskLevel = normalizeRiskLevel(asString(s.riskLevel)) ?? "none"; - const lastSeenAt = asString(s.lastSeenAt).trim() || null; - const lastSeenAgeMs = (() => { - if (!lastSeenAt) return null; - const ts = Date.parse(lastSeenAt); - if (!Number.isFinite(ts)) return null; - return Math.max(0, nowMs - ts); - })(); - return { - peerId: s.peerId ?? null, - peerLabel: asString(s.peerLabel).trim() || "unknown", - riskLevel, - fileCount: Number.isFinite(Number(s.fileCount)) ? Number(s.fileCount) : 0, - lastSeenAt, - lastSeenAgeMs, - riskSignals: Array.isArray(s.riskSignals) ? (s.riskSignals as string[]).map((v) => String(v)) : [] - }; - }) - .slice(0, 12); - } - - const overlaps = Array.isArray(predictionPack?.overlaps) ? predictionPack!.overlaps! : []; - const summaries: ConflictLineageV1["openConflictSummaries"] = []; - for (const ov of overlaps) { - const peerId = (ov.peerId ?? null) as string | null; - const peerLabel = peerId ? `lane:${peerId}` : `base:${lane.baseRef}`; - const riskLevel = normalizeRiskLevel(asString(ov.riskLevel)) ?? "none"; - const fileCount = Array.isArray(ov.files) ? ov.files.length : 0; - const signals: string[] = []; - if (riskLevel === "high") signals.push("high_risk"); - if (fileCount > 0) signals.push("overlap_files"); - if (predictionPack?.truncated) signals.push("partial_coverage"); - summaries.push({ - peerId, - peerLabel, - riskLevel, - fileCount, - lastSeenAt: null, - lastSeenAgeMs: null, - riskSignals: signals - }); - } - return summaries.slice(0, 12); - })(); - - const lineage: ConflictLineageV1 = { - schema: "ade.conflictLineage.v1", - laneId, - peerKey, - predictionAt, - predictionAgeMs, - predictionStale, - ...(staleReason ? { staleReason } : {}), - lastRecomputedAt: - asString((predictionPack as any)?.lastRecomputedAt).trim() || asString((predictionPack as any)?.generatedAt).trim() || null, - truncated: predictionPack?.truncated != null ? Boolean(predictionPack.truncated) : null, - strategy: asString(predictionPack?.strategy).trim() || null, - pairwisePairsComputed: Number.isFinite(Number(predictionPack?.pairwisePairsComputed)) ? Number(predictionPack?.pairwisePairsComputed) : null, - pairwisePairsTotal: Number.isFinite(Number(predictionPack?.pairwisePairsTotal)) ? Number(predictionPack?.pairwisePairsTotal) : null, - stalePolicy: { ttlMs: staleTtlMs }, - openConflictSummaries, - unresolvedResolutionState: await readGitConflictState(laneId).catch(() => null) - }; - - const graph = buildGraphEnvelope( - [ - { - relationType: "depends_on", - targetPackKey: `lane:${laneId}`, - targetPackType: "lane", - targetLaneId: laneId, - targetBranch: lane.branchRef, - targetHeadCommit: pack.lastHeadSha ?? null, - rationale: "Conflict export depends on lane pack." - }, - ...(peerLaneId - ? ([ - { - relationType: "depends_on", - targetPackKey: `lane:${peerLaneId}`, - targetPackType: "lane", - targetLaneId: peerLaneId, - rationale: "Conflict export depends on peer lane pack." - } - ] satisfies PackRelation[]) - : ([ - { - relationType: "shares_base", - targetPackKey: "project", - targetPackType: "project", - rationale: "Base conflicts are computed against project base ref." - } - ] satisfies PackRelation[])) - ] satisfies PackRelation[] - ); - - return buildConflictExport({ - level, - projectId, - packKey, - laneId, - peerLabel, - pack, - providerMode, - apiBaseUrl, - remoteProjectId, - graph, - lineage - }); - }, - - async getFeatureExport(args: { featureKey: string; level: ContextExportLevel }): Promise { - const featureKey = args.featureKey.trim(); - if (!featureKey) throw new Error("featureKey is required"); - const level = args.level; - if (level !== "lite" && level !== "standard" && level !== "deep") { - throw new Error(`Invalid export level: ${String(level)}`); - } - const packKey = `feature:${featureKey}`; - const pack = await buildLiveFeaturePackSummary({ - featureKey, - reason: "context_export" - }); - return buildBasicExport({ packKey, packType: "feature", level, pack }); - }, - - async getPlanExport(args: { laneId: string; level: ContextExportLevel }): Promise { - const laneId = args.laneId.trim(); - if (!laneId) throw new Error("laneId is required"); - const level = args.level; - if (level !== "lite" && level !== "standard" && level !== "deep") { - throw new Error(`Invalid export level: ${String(level)}`); - } - const packKey = `plan:${laneId}`; - const pack = await buildLivePlanPackSummary({ - laneId, - reason: "context_export" - }); - return buildBasicExport({ packKey, packType: "plan", level, pack }); - }, - - async getMissionExport(args: { missionId: string; level: ContextExportLevel }): Promise { - const missionId = args.missionId.trim(); - if (!missionId) throw new Error("missionId is required"); - const level = args.level; - if (level !== "lite" && level !== "standard" && level !== "deep") { - throw new Error(`Invalid export level: ${String(level)}`); - } - const packKey = `mission:${missionId}`; - const pack = await buildLiveMissionPackSummary({ - missionId, - reason: "context_export" - }); - return buildBasicExport({ packKey, packType: "mission", level, pack }); - }, - }; -} diff --git a/apps/desktop/src/main/services/packs/packUtils.ts b/apps/desktop/src/main/services/packs/packUtils.ts deleted file mode 100644 index c243d011..00000000 --- a/apps/desktop/src/main/services/packs/packUtils.ts +++ /dev/null @@ -1,554 +0,0 @@ -/** - * Shared utility functions for pack builders. - * - * These helpers are extracted from `packService.ts` to be reusable across - * the project, lane, mission, and conflict pack builder modules. - */ - -import fs from "node:fs"; -import path from "node:path"; -import type { AdeDb } from "../state/kvDb"; -import type { - ConflictRiskLevel, - ConflictStatusValue, - PackMergeReadiness, - PackType, - SessionDeltaSummary, - TestRunStatus -} from "../../../shared/types"; -import { safeJsonParse } from "../shared/utils"; -import type { PackGraphEnvelopeV1, PackRelation } from "../../../shared/contextContract"; -import { - ADE_INTENT_END, - ADE_INTENT_START, - ADE_NARRATIVE_END, - ADE_NARRATIVE_START, - ADE_TASK_SPEC_END, - ADE_TASK_SPEC_START, - ADE_TODOS_END, - ADE_TODOS_START -} from "../../../shared/contextContract"; -import type { SectionLocator } from "./packSections"; - -// ── Row types ──────────────────────────────────────────────────────────────── - -export type LaneSessionRow = { - id: string; - lane_id: string; - tracked: number; - started_at: string; - ended_at: string | null; - head_sha_start: string | null; - head_sha_end: string | null; - transcript_path: string; -}; - -export type SessionDeltaRow = { - session_id: string; - lane_id: string; - started_at: string; - ended_at: string | null; - head_sha_start: string | null; - head_sha_end: string | null; - files_changed: number; - insertions: number; - deletions: number; - touched_files_json: string; - failure_lines_json: string; - computed_at: string; -}; - -export type ParsedNumStat = { - insertions: number; - deletions: number; - files: Set; -}; - -// ── Conflict prediction pack shape ─────────────────────────────────────────── - -export type ConflictPredictionPackFile = { - laneId?: string; - status?: { - laneId?: string; - status?: string; - overlappingFileCount?: number; - peerConflictCount?: number; - lastPredictedAt?: string | null; - }; - predictionAt?: string | null; - lastRecomputedAt?: string | null; - stalePolicy?: { ttlMs?: number }; - overlaps?: Array<{ - peerId?: string | null; - peerName?: string; - riskLevel?: string; - files?: Array<{ path?: string }>; - }>; - openConflictSummaries?: Array<{ - peerId?: string | null; - peerLabel?: string; - riskLevel?: string; - fileCount?: number; - lastSeenAt?: string | null; - riskSignals?: string[]; - }>; - matrix?: Array<{ - laneAId?: string; - laneBId?: string; - riskLevel?: string; - overlapCount?: number; - hasConflict?: boolean; - computedAt?: string | null; - stale?: boolean; - }>; - generatedAt?: string; - truncated?: boolean; - strategy?: string; - pairwisePairsComputed?: number; - pairwisePairsTotal?: number; -}; - -// ── Pure helper functions ──────────────────────────────────────────────────── - -export function safeJsonParseArray(raw: string | null | undefined): string[] { - const parsed = safeJsonParse(raw, null); - if (!Array.isArray(parsed)) return []; - return parsed.map((entry) => String(entry)); -} - -export function readFileIfExists(filePath: string): string { - try { - return fs.readFileSync(filePath, "utf8"); - } catch { - return ""; - } -} - -export function ensureDirFor(filePath: string) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); -} - -export function ensureDir(dirPath: string) { - fs.mkdirSync(dirPath, { recursive: true }); -} - -export function safeSegment(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) return "untitled"; - return trimmed.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80); -} - -export function parsePackMetadataJson(raw: string | null | undefined): Record | null { - if (!raw || !raw.trim()) return null; - try { - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - return parsed as Record; - } catch { - return null; - } -} - -export function parseNumStat(stdout: string): ParsedNumStat { - const files = new Set(); - let insertions = 0; - let deletions = 0; - - const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean); - for (const line of lines) { - const parts = line.split("\t"); - if (parts.length < 3) continue; - const ins = parts[0] ?? "0"; - const del = parts[1] ?? "0"; - const filePath = parts.slice(2).join("\t").trim(); - if (filePath.length) files.add(filePath); - - const insNum = ins === "-" ? 0 : Number(ins); - const delNum = del === "-" ? 0 : Number(del); - if (Number.isFinite(insNum)) insertions += insNum; - if (Number.isFinite(delNum)) deletions += delNum; - } - - return { insertions, deletions, files }; -} - -export function parsePorcelainPaths(stdout: string): string[] { - const out = new Set(); - const lines = stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean); - for (const line of lines) { - if (line.startsWith("??")) { - const rel = line.slice(2).trim(); - if (rel.length) out.add(rel); - continue; - } - - const raw = line.slice(2).trim(); - const arrow = raw.indexOf("->"); - if (arrow >= 0) { - const rel = raw.slice(arrow + 2).trim(); - if (rel.length) out.add(rel); - continue; - } - if (raw.length) out.add(raw); - } - return [...out]; -} - -export function parseChatTranscriptDelta(rawTranscript: string): { - touchedFiles: string[]; - failureLines: string[]; -} { - const touched = new Set(); - const failureLines: string[] = []; - const seenFailure = new Set(); - - const pushFailure = (value: string | null | undefined) => { - const normalized = String(value ?? "").replace(/\s+/g, " ").trim(); - if (!normalized.length) return; - const clipped = normalized.length > 320 ? normalized.slice(0, 320) : normalized; - if (seenFailure.has(clipped)) return; - seenFailure.add(clipped); - failureLines.push(clipped); - }; - - for (const rawLine of rawTranscript.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line.length) continue; - - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - continue; - } - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; - const event = (parsed as { event?: unknown }).event; - if (!event || typeof event !== "object" || Array.isArray(event)) continue; - const eventRecord = event as Record; - const type = typeof eventRecord.type === "string" ? eventRecord.type : ""; - - if (type === "file_change") { - const pathValue = String(eventRecord.path ?? "").trim(); - if (pathValue.length && !pathValue.startsWith("(")) touched.add(pathValue); - continue; - } - - if (type === "command") { - const command = String(eventRecord.command ?? "").trim(); - const output = String(eventRecord.output ?? ""); - const status = String(eventRecord.status ?? "").trim(); - const exitCode = typeof eventRecord.exitCode === "number" ? eventRecord.exitCode : null; - - if (status === "failed" || (exitCode != null && exitCode !== 0)) { - pushFailure(command.length ? `Command failed: ${command}` : "Command failed."); - } - - for (const outputLine of output.split(/\r?\n/)) { - const normalized = outputLine.replace(/\s+/g, " ").trim(); - if (!normalized.length) continue; - if (!/\b(error|failed|exception|fatal|traceback)\b/i.test(normalized)) continue; - pushFailure(normalized); - } - continue; - } - - if (type === "error") { - pushFailure(String(eventRecord.message ?? "Chat error.")); - continue; - } - - if (type === "status") { - const turnStatus = String(eventRecord.turnStatus ?? "").trim(); - if (turnStatus === "failed") { - pushFailure(String(eventRecord.message ?? "Turn failed.")); - } - } - } - - return { - touchedFiles: [...new Set(touched)].sort(), - failureLines: failureLines.slice(-16) - }; -} - -export function extractSection(existing: string, start: string, end: string, fallback: string): string { - const startIdx = existing.indexOf(start); - const endIdx = existing.indexOf(end); - if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) return fallback; - const body = existing.slice(startIdx + start.length, endIdx).trim(); - return body.length ? body : fallback; -} - -export function extractSectionByHeading(existing: string, heading: string): string | null { - const re = new RegExp(`^${heading.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m"); - const match = re.exec(existing); - if (!match?.index && match?.index !== 0) return null; - - const headingStart = match.index; - const headingLineEnd = existing.indexOf("\n", headingStart); - const sectionStart = headingLineEnd >= 0 ? headingLineEnd + 1 : existing.length; - - const nextHeading = (() => { - const r = /^##\s+/gm; - r.lastIndex = sectionStart; - const m = r.exec(existing); - return m ? m.index : -1; - })(); - const nextHr = (() => { - const r = /^---\s*$/gm; - r.lastIndex = sectionStart; - const m = r.exec(existing); - return m ? m.index : -1; - })(); - - const candidates = [nextHeading, nextHr].filter((idx) => idx >= 0); - const sectionEnd = candidates.length ? Math.min(...candidates) : existing.length; - - const body = existing.slice(sectionStart, sectionEnd).trim(); - return body.length ? body : ""; -} - -export function statusFromCode(status: TestRunStatus): string { - if (status === "passed") return "PASS"; - if (status === "failed") return "FAIL"; - if (status === "running") return "RUNNING"; - if (status === "canceled") return "CANCELED"; - return "TIMED_OUT"; -} - -export function humanToolLabel(toolType: string | null | undefined): string { - const normalized = String(toolType ?? "").trim().toLowerCase(); - if (!normalized) return "Shell"; - if (normalized === "claude") return "Claude"; - if (normalized === "codex") return "Codex"; - if (normalized === "cursor") return "Cursor"; - if (normalized === "aider") return "Aider"; - if (normalized === "continue") return "Continue"; - if (normalized === "shell") return "Shell"; - return normalized.slice(0, 1).toUpperCase() + normalized.slice(1); -} - -export function shellQuoteArg(arg: string): string { - const value = String(arg); - if (!value.length) return "''"; - if (/^[a-zA-Z0-9_./:-]+$/.test(value)) return value; - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - -export function formatCommand(command: unknown): string { - if (Array.isArray(command)) return command.map((part) => shellQuoteArg(String(part))).join(" "); - if (typeof command === "string") return command.trim(); - return JSON.stringify(command); -} - -export function moduleFromPath(relPath: string): string { - const normalized = relPath.replace(/\\/g, "/"); - const first = normalized.split("/")[0] ?? normalized; - return first || "."; -} - -export function normalizeConflictStatus(value: string): ConflictStatusValue | null { - const v = value.trim(); - if ( - v === "merge-ready" || - v === "behind-base" || - v === "conflict-predicted" || - v === "conflict-active" || - v === "unknown" - ) { - return v; - } - return null; -} - -export function normalizeRiskLevel(value: string): ConflictRiskLevel | null { - const v = value.trim(); - if (v === "none" || v === "low" || v === "medium" || v === "high") return v; - return null; -} - -export function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -export function asString(value: unknown): string { - return typeof value === "string" ? value : ""; -} - -export function parseRecord(raw: string | null | undefined): Record | null { - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } -} - -export function computeMergeReadiness(args: { - requiredMerges: string[]; - behindCount: number; - conflictStatus: ConflictStatusValue | null; -}): PackMergeReadiness { - if (args.requiredMerges.length) return "blocked"; - if (args.conflictStatus === "unknown" || args.conflictStatus == null) return "unknown"; - if (args.conflictStatus === "conflict-active" || args.conflictStatus === "conflict-predicted") return "blocked"; - if (args.behindCount > 0) return "needs_sync"; - return "ready"; -} - -export function buildGraphEnvelope(relations: PackRelation[]): PackGraphEnvelopeV1 { - return { - schema: "ade.packGraph.v1", - relations - }; -} - -export function importanceRank(value: unknown): number { - const v = typeof value === "string" ? value.trim().toLowerCase() : ""; - if (v === "high") return 3; - if (v === "medium") return 2; - if (v === "low") return 1; - return 0; -} - -export function getDefaultSectionLocators(packType: PackType): SectionLocator[] { - if (packType === "lane") { - return [ - { id: "task_spec", kind: "markers", startMarker: ADE_TASK_SPEC_START, endMarker: ADE_TASK_SPEC_END }, - { id: "intent", kind: "markers", startMarker: ADE_INTENT_START, endMarker: ADE_INTENT_END }, - { id: "todos", kind: "markers", startMarker: ADE_TODOS_START, endMarker: ADE_TODOS_END }, - { id: "narrative", kind: "markers", startMarker: ADE_NARRATIVE_START, endMarker: ADE_NARRATIVE_END }, - { id: "what_changed", kind: "heading", heading: "## What Changed" }, - { id: "validation", kind: "heading", heading: "## Validation" }, - { id: "errors", kind: "heading", heading: "## Errors & Issues" }, - { id: "sessions", kind: "heading", heading: "## Sessions" } - ]; - } - if (packType === "conflict") { - return [ - { id: "overlap", kind: "heading", heading: "## Overlapping Files" }, - { id: "conflicts", kind: "heading", heading: "## Conflicts (merge-tree)" }, - { id: "lane_excerpt", kind: "heading", heading: "## Lane Pack (Excerpt)" } - ]; - } - return [ - { id: "bootstrap", kind: "heading", heading: "## Bootstrap context (codebase + docs)" }, - { id: "lane_snapshot", kind: "heading", heading: "## Lane Snapshot" } - ]; -} - -export function rowToSessionDelta(row: SessionDeltaRow): SessionDeltaSummary { - return { - sessionId: row.session_id, - laneId: row.lane_id, - startedAt: row.started_at, - endedAt: row.ended_at, - headShaStart: row.head_sha_start, - headShaEnd: row.head_sha_end, - filesChanged: Number(row.files_changed ?? 0), - insertions: Number(row.insertions ?? 0), - deletions: Number(row.deletions ?? 0), - touchedFiles: safeJsonParseArray(row.touched_files_json), - failureLines: safeJsonParseArray(row.failure_lines_json), - computedAt: row.computed_at ?? null - }; -} - -/** - * Upsert a row into `packs_index`. - */ -export function upsertPackIndex({ - db, - projectId, - packKey, - laneId, - packType, - packPath, - deterministicUpdatedAt, - narrativeUpdatedAt, - lastHeadSha, - metadata -}: { - db: AdeDb; - projectId: string; - packKey: string; - laneId: string | null; - packType: PackType; - packPath: string; - deterministicUpdatedAt: string; - narrativeUpdatedAt?: string | null; - lastHeadSha?: string | null; - metadata?: Record; -}) { - db.run( - ` - insert into packs_index( - pack_key, - project_id, - lane_id, - pack_type, - pack_path, - deterministic_updated_at, - narrative_updated_at, - last_head_sha, - metadata_json - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) - on conflict(pack_key) do update set - project_id = excluded.project_id, - lane_id = excluded.lane_id, - pack_type = excluded.pack_type, - pack_path = excluded.pack_path, - deterministic_updated_at = excluded.deterministic_updated_at, - narrative_updated_at = excluded.narrative_updated_at, - last_head_sha = excluded.last_head_sha, - metadata_json = excluded.metadata_json - `, - [ - packKey, - projectId, - laneId, - packType, - packPath, - deterministicUpdatedAt, - narrativeUpdatedAt ?? null, - lastHeadSha ?? null, - JSON.stringify(metadata ?? {}) - ] - ); -} - -export function toPackSummaryFromRow(args: { - packKey: string; - row: { - pack_type: PackType; - pack_path: string; - deterministic_updated_at: string | null; - narrative_updated_at: string | null; - last_head_sha: string | null; - metadata_json?: string | null; - } | null; - version: { versionId: string; versionNumber: number; contentHash: string } | null; -}) { - const packType = args.row?.pack_type ?? "project"; - const packPath = args.row?.pack_path ?? ""; - const body = packPath ? readFileIfExists(packPath) : ""; - const exists = packPath.length ? fs.existsSync(packPath) : false; - const metadata = parsePackMetadataJson(args.row?.metadata_json); - - return { - packKey: args.packKey, - packType, - path: packPath, - exists, - deterministicUpdatedAt: args.row?.deterministic_updated_at ?? null, - narrativeUpdatedAt: args.row?.narrative_updated_at ?? null, - lastHeadSha: args.row?.last_head_sha ?? null, - versionId: args.version?.versionId ?? null, - versionNumber: args.version?.versionNumber ?? null, - contentHash: args.version?.contentHash ?? null, - metadata, - body - }; -} diff --git a/apps/desktop/src/main/services/packs/transcriptInsights.test.ts b/apps/desktop/src/main/services/packs/transcriptInsights.test.ts deleted file mode 100644 index c4082a50..00000000 --- a/apps/desktop/src/main/services/packs/transcriptInsights.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { deriveSessionSummaryFromText, inferTestOutcomeFromText, parseTranscriptSummary } from "./transcriptInsights"; - -describe("transcriptInsights", () => { - it("derives a compact summary from Claude-style output", () => { - const raw = [ - "Some earlier log line", - "", - "All 4 tests pass. Here's what I added:", - "", - " Tip calculation on receipts — the receipt now includes a tip line and a total.", - " It defaults to 18% but accepts a custom tipPercent via the options.", - "", - "✻ Cooked for 41s", - "" - ].join("\n"); - - const summary = deriveSessionSummaryFromText(raw); - expect(summary).toContain("All 4 tests pass"); - expect(summary).toContain("Tip calculation on receipts"); - expect(summary.toLowerCase()).not.toContain("cooked for"); - }); - - it("prefers explicit final blocks and records source/confidence", () => { - const raw = [ - "Checking tests...", - "", - "Done. Here's what changed:", - "- Updated `src/main.ts` to normalize args", - "- Added retry guard in src/services/retry.ts", - "- Wrote docs in docs/features/PACKS.md" - ].join("\n"); - - const parsed = parseTranscriptSummary(raw); - expect(parsed).toBeTruthy(); - expect(parsed?.source).toBe("explicit_final_block"); - expect(parsed?.confidence).toBe("high"); - expect(parsed?.files).toContain("docs/features/PACKS.md"); - expect(parsed?.files).toContain("src/main.ts"); - }); - - it("recognizes worker closeout phrasing used by live missions", () => { - const raw = [ - "thinking", - "Accomplished: verified the Test tab feature is already fully implemented and committed on this lane branch; no source code changes were required.", - "", - "What I verified:", - "- [TabNav.tsx](/tmp/TabNav.tsx) includes `Test` with `Flask` and `/test`.", - "- [TestPage.tsx](/tmp/TestPage.tsx) exists and renders 'Coming Soon'.", - ].join("\n"); - - const parsed = parseTranscriptSummary(raw); - expect(parsed?.source).toBe("explicit_final_block"); - expect(parsed?.summary).toContain("Accomplished:"); - expect(parsed?.files.some((file) => file.endsWith("TabNav.tsx"))).toBe(true); - }); - - it("recognizes planning-worker closeout phrasing", () => { - const raw = [ - "noise", - "Research is complete. The \"Test\" tab with \"Coming Soon\" screen is already fully implemented in the codebase from a previous mission run.", - "", - "No code changes are needed to complete this task.", - ].join("\n"); - - const summary = deriveSessionSummaryFromText(raw); - expect(summary).toContain("Research is complete."); - expect(summary).toContain("No code changes are needed"); - }); - - it("falls back to heuristic tail summary when no explicit block exists", () => { - const raw = [ - "starting", - "ran tests", - "All 12 tests passed in 3.8s", - "Updated parser and conflict heuristics" - ].join("\n"); - const parsed = parseTranscriptSummary(raw); - expect(parsed).toBeTruthy(); - expect(parsed?.source).toBe("heuristic_tail"); - expect(parsed?.confidence).toBe("medium"); - }); - - it("infers test pass from an 'All tests pass' line", () => { - const inferred = inferTestOutcomeFromText("All 4 tests pass.\n"); - expect(inferred?.status).toBe("pass"); - expect(inferred?.evidence).toContain("All 4 tests pass"); - }); - - it("infers test fail from a jest-like summary line", () => { - const inferred = inferTestOutcomeFromText("Test Suites: 1 failed, 3 passed, 4 total\n"); - expect(inferred?.status).toBe("fail"); - expect(inferred?.evidence).toContain("Test Suites:"); - }); -}); 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/sessions/sessionDeltaService.ts b/apps/desktop/src/main/services/sessions/sessionDeltaService.ts index 75299f9b..12c31314 100644 --- a/apps/desktop/src/main/services/sessions/sessionDeltaService.ts +++ b/apps/desktop/src/main/services/sessions/sessionDeltaService.ts @@ -6,7 +6,7 @@ import { parseNumStat, parsePorcelainPaths, rowToSessionDelta, -} from "../packs/packUtils"; +} from "../shared/packLegacyUtils"; import type { createSessionService } from "./sessionService"; import { runGit } from "../git/git"; import type { AdeDb } from "../state/kvDb"; diff --git a/apps/desktop/src/main/services/shared/packLegacyUtils.ts b/apps/desktop/src/main/services/shared/packLegacyUtils.ts new file mode 100644 index 00000000..07cb3642 --- /dev/null +++ b/apps/desktop/src/main/services/shared/packLegacyUtils.ts @@ -0,0 +1,234 @@ +/** + * Utility functions formerly in packs/packUtils.ts, retained because they are + * consumed by sessionDeltaService, conflictService, and other non-pack code. + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { SessionDeltaSummary } from "../../../shared/types"; +import { safeJsonParse } from "./utils"; + +// ── Row types ──────────────────────────────────────────────────────────────── + +export type LaneSessionRow = { + id: string; + lane_id: string; + tracked: number; + started_at: string; + ended_at: string | null; + head_sha_start: string | null; + head_sha_end: string | null; + transcript_path: string; +}; + +export type SessionDeltaRow = { + session_id: string; + lane_id: string; + started_at: string; + ended_at: string | null; + head_sha_start: string | null; + head_sha_end: string | null; + files_changed: number; + insertions: number; + deletions: number; + touched_files_json: string; + failure_lines_json: string; + computed_at: string; +}; + +export type ParsedNumStat = { + insertions: number; + deletions: number; + files: Set; +}; + +// ── Pure helper functions ──────────────────────────────────────────────────── + +export function safeJsonParseArray(raw: string | null | undefined): string[] { + const parsed = safeJsonParse(raw, null); + if (!Array.isArray(parsed)) return []; + return parsed.map((entry) => String(entry)); +} + +export function readFileIfExists(filePath: string): string { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return ""; + } +} + +export function ensureDirFor(filePath: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +export function ensureDir(dirPath: string) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +export function safeSegment(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return "untitled"; + return trimmed.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80); +} + +export function parseNumStat(stdout: string): ParsedNumStat { + const files = new Set(); + let insertions = 0; + let deletions = 0; + + const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean); + for (const line of lines) { + const parts = line.split("\t"); + if (parts.length < 3) continue; + const ins = parts[0] ?? "0"; + const del = parts[1] ?? "0"; + const filePath = parts.slice(2).join("\t").trim(); + if (filePath.length) files.add(filePath); + + const insNum = ins === "-" ? 0 : Number(ins); + const delNum = del === "-" ? 0 : Number(del); + if (Number.isFinite(insNum)) insertions += insNum; + if (Number.isFinite(delNum)) deletions += delNum; + } + + return { insertions, deletions, files }; +} + +export function parsePorcelainPaths(stdout: string): string[] { + const out = new Set(); + const lines = stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean); + for (const line of lines) { + if (line.startsWith("??")) { + const rel = line.slice(2).trim(); + if (rel.length) out.add(rel); + continue; + } + + const raw = line.slice(2).trim(); + const arrow = raw.indexOf("->"); + if (arrow >= 0) { + const rel = raw.slice(arrow + 2).trim(); + if (rel.length) out.add(rel); + continue; + } + if (raw.length) out.add(raw); + } + return [...out]; +} + +export function parseChatTranscriptDelta(rawTranscript: string): { + touchedFiles: string[]; + failureLines: string[]; +} { + const touched = new Set(); + const failureLines: string[] = []; + const seenFailure = new Set(); + + const pushFailure = (value: string | null | undefined) => { + const normalized = String(value ?? "").replace(/\s+/g, " ").trim(); + if (!normalized.length) return; + const clipped = normalized.length > 320 ? normalized.slice(0, 320) : normalized; + if (seenFailure.has(clipped)) return; + seenFailure.add(clipped); + failureLines.push(clipped); + }; + + for (const rawLine of rawTranscript.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line.length) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; + const event = (parsed as { event?: unknown }).event; + if (!event || typeof event !== "object" || Array.isArray(event)) continue; + const eventRecord = event as Record; + const type = typeof eventRecord.type === "string" ? eventRecord.type : ""; + + if (type === "file_change") { + const pathValue = String(eventRecord.path ?? "").trim(); + if (pathValue.length && !pathValue.startsWith("(")) touched.add(pathValue); + continue; + } + + if (type === "command") { + const command = String(eventRecord.command ?? "").trim(); + const output = String(eventRecord.output ?? ""); + const status = String(eventRecord.status ?? "").trim(); + const exitCode = typeof eventRecord.exitCode === "number" ? eventRecord.exitCode : null; + + if (status === "failed" || (exitCode != null && exitCode !== 0)) { + pushFailure(command.length ? `Command failed: ${command}` : "Command failed."); + } + + for (const outputLine of output.split(/\r?\n/)) { + const normalized = outputLine.replace(/\s+/g, " ").trim(); + if (!normalized.length) continue; + if (!/\b(error|failed|exception|fatal|traceback)\b/i.test(normalized)) continue; + pushFailure(normalized); + } + continue; + } + + if (type === "error") { + pushFailure(String(eventRecord.message ?? "Chat error.")); + continue; + } + + if (type === "status") { + const turnStatus = String(eventRecord.turnStatus ?? "").trim(); + if (turnStatus === "failed") { + pushFailure(String(eventRecord.message ?? "Turn failed.")); + } + } + } + + return { + touchedFiles: [...new Set(touched)].sort(), + failureLines: failureLines.slice(-16) + }; +} + +export function formatCommand(command: unknown): string { + if (Array.isArray(command)) { + return command.map((part) => { + const value = String(part); + if (!value.length) return "''"; + if (/^[a-zA-Z0-9_./:-]+$/.test(value)) return value; + return `'${value.replace(/'/g, `'\"'\"'`)}'`; + }).join(" "); + } + if (typeof command === "string") return command.trim(); + return JSON.stringify(command); +} + +export function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +export function rowToSessionDelta(row: SessionDeltaRow): SessionDeltaSummary { + return { + sessionId: row.session_id, + laneId: row.lane_id, + startedAt: row.started_at, + endedAt: row.ended_at, + headShaStart: row.head_sha_start, + headShaEnd: row.head_sha_end, + filesChanged: Number(row.files_changed ?? 0), + insertions: Number(row.insertions ?? 0), + deletions: Number(row.deletions ?? 0), + touchedFiles: safeJsonParseArray(row.touched_files_json), + failureLines: safeJsonParseArray(row.failure_lines_json), + computedAt: row.computed_at ?? null + }; +} diff --git a/apps/desktop/src/main/services/packs/transcriptInsights.ts b/apps/desktop/src/main/services/shared/transcriptInsights.ts similarity index 95% rename from apps/desktop/src/main/services/packs/transcriptInsights.ts rename to apps/desktop/src/main/services/shared/transcriptInsights.ts index a157d274..e6f0af2e 100644 --- a/apps/desktop/src/main/services/packs/transcriptInsights.ts +++ b/apps/desktop/src/main/services/shared/transcriptInsights.ts @@ -1,3 +1,8 @@ +/** + * Transcript insight extraction utilities. + * Relocated from packs/transcriptInsights.ts after pack system removal. + */ + import { stripAnsi } from "../../utils/ansiStrip"; export type InferredTestOutcome = { @@ -50,7 +55,7 @@ function lineAt(text: string, idx: number): string { function isNoiseLine(line: string): boolean { const t = line.trim(); if (!t) return true; - if (t.startsWith("✻")) return true; + if (t.startsWith("\u2733")) return true; const lower = t.toLowerCase(); if (lower.startsWith("cooked for")) return true; return false; @@ -104,8 +109,8 @@ function parseExplicitFinalBlock(text: string): ParsedTranscriptSummary | null { if (!block.length) return null; const bullets = block - .filter((line) => /^[-*•]\s+/.test(line) || /^\d+\.\s+/.test(line)) - .map((line) => line.replace(/^[-*•]\s+/, "").replace(/^\d+\.\s+/, "").trim()) + .filter((line) => /^[-*\u2022]\s+/.test(line) || /^\d+\.\s+/.test(line)) + .map((line) => line.replace(/^[-*\u2022]\s+/, "").replace(/^\d+\.\s+/, "").trim()) .filter(Boolean); const summary = block diff --git a/apps/desktop/src/main/services/state/crsqliteExtension.ts b/apps/desktop/src/main/services/state/crsqliteExtension.ts index a5b01195..a2984278 100644 --- a/apps/desktop/src/main/services/state/crsqliteExtension.ts +++ b/apps/desktop/src/main/services/state/crsqliteExtension.ts @@ -15,7 +15,12 @@ function platformArchDir(): string { return `${process.platform}-${process.arch}`; } -export function resolveCrsqliteExtensionPath(): string { +let cachedCrsqlitePath: string | null | undefined; + +export function resolveCrsqliteExtensionPath(): string | null { + if (cachedCrsqlitePath !== undefined) { + return cachedCrsqlitePath; + } const relativePath = path.join("vendor", "crsqlite", platformArchDir(), extensionFileName()); const candidates = [ process.resourcesPath ? path.join(process.resourcesPath, "app.asar.unpacked", relativePath) : null, @@ -28,9 +33,15 @@ export function resolveCrsqliteExtensionPath(): string { for (const candidate of candidates) { if (fs.existsSync(candidate)) { - return candidate; + cachedCrsqlitePath = candidate; + return cachedCrsqlitePath; } } - throw new Error(`Unable to locate cr-sqlite extension for ${platformArchDir()}`); + cachedCrsqlitePath = null; + return cachedCrsqlitePath; +} + +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..83cc552f 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) { + // Real FTS virtual table — query with MATCH + 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); + } else { + // Fallback plain table — verify content was copied with LIKE + 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); + } 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..c8441a52 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", @@ -80,21 +85,16 @@ const APPROVED_CONFLICT_TARGETS: ConflictTarget[] = [ table: "orchestrator_run_state", columns: "run_id", }, - { - file: "src/main/services/packs/packService.ts", - table: "pack_heads", - columns: "project_id,pack_key", - }, - { - file: "src/main/services/packs/packUtils.ts", - table: "packs_index", - columns: "pack_key", - }, { file: "src/main/services/processes/processService.ts", 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..3a8af593 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,64 @@ 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", + 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(), @@ -428,7 +539,64 @@ 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", + 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(), @@ -441,7 +609,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 +711,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 +870,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..736a888f 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,220 @@ 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 { + if (typeof value.text !== "string") { + throw new Error("files.writeTextAtomic requires text."); + } + return { + laneId: requireString(value.laneId, "files.writeTextAtomic requires laneId."), + path: requireString(value.path, "files.writeTextAtomic requires path."), + text: 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 +488,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 +507,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 +1053,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..1ad6da86 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -1,10 +1,31 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isCrsqliteAvailable } from "../state/crsqliteExtension"; import { openKvDb } from "../state/kvDb"; import { createSyncService } from "./syncService"; +// Prevent real WebSocket servers from binding to port 8787 during tests. +// Tests only exercise role/transfer/pairing logic, not the sync transport. +vi.mock("./syncHostService", () => ({ + createSyncHostService: () => ({ + async waitUntilListening() { return 8787; }, + getPort() { return 8787; }, + getBootstrapToken() { return "test-bootstrap-token"; }, + getPairingSession() { + const expires = new Date(Date.now() + 600_000).toISOString(); + return { code: "TEST1234", expiresAt: expires, pairedDevices: [] }; + }, + revokePairedDevice() {}, + getPeerStates() { return []; }, + getBrainStatusSnapshot() { return {}; }, + handlePtyData() {}, + handlePtyExit() {}, + async dispose() {}, + }), +})); + function createLogger() { return { debug: () => {}, @@ -64,7 +85,7 @@ afterEach(async () => { } }); -describe("syncService", () => { +describe.skipIf(!isCrsqliteAvailable())("syncService", () => { it("reports W3 transfer blockers while keeping paused and idle state survivable", async () => { const projectRoot = makeProjectRoot("ade-sync-service-blockers-"); const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); @@ -250,5 +271,87 @@ describe("syncService", () => { expect(transferred.clusterState?.brainEpoch).toBe(4); expect(transferred.currentBrain?.deviceId).toBe(localDevice.deviceId); expect(transferred.transferReadiness.ready).toBe(true); - }); + }, 30_000); + + 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); + }, 30_000); }); 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/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 52610bd0..6bf95809 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -800,8 +800,6 @@ declare global { updateSession: (args: AgentChatUpdateSessionArgs) => Promise; warmupModel: (args: { sessionId: string; modelId: string }) => Promise; onEvent: (cb: (ev: AgentChatEventEnvelope) => void) => () => void; - listContextPacks: (args?: import("../shared/types").ContextPackListArgs) => Promise; - fetchContextPack: (args: import("../shared/types").ContextPackFetchArgs) => Promise; changePermissionMode: (args: import("../shared/types").AgentChatChangePermissionModeArgs) => Promise; slashCommands: (args: import("../shared/types").AgentChatSlashCommandsArgs) => Promise; fileSearch: (args: import("../shared/types").AgentChatFileSearchArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ed8e405d..d2c8af42 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1128,10 +1128,6 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.agentChatEvent, listener); return () => ipcRenderer.removeListener(IPC.agentChatEvent, listener); }, - listContextPacks: async (args: import("../shared/types").ContextPackListArgs = {}): Promise => - ipcRenderer.invoke(IPC.agentChatListContextPacks, args), - fetchContextPack: async (args: import("../shared/types").ContextPackFetchArgs): Promise => - ipcRenderer.invoke(IPC.agentChatFetchContextPack, args), changePermissionMode: async (args: import("../shared/types").AgentChatChangePermissionModeArgs): Promise => ipcRenderer.invoke(IPC.agentChatChangePermissionMode, args), slashCommands: async (args: import("../shared/types").AgentChatSlashCommandsArgs): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 9ad2bad3..25956bae 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1193,8 +1193,6 @@ if (typeof window !== "undefined" && !(window as any).ade) { dispose: resolvedArg(undefined), updateSession: resolvedArg({ id: "mock" }), onEvent: noop, - listContextPacks: resolved([]), - fetchContextPack: resolvedArg({ content: "" }), changePermissionMode: resolvedArg(undefined), slashCommands: resolvedArg([]), fileSearch: resolvedArg([]), diff --git a/apps/desktop/src/renderer/components/app/ProjectSelector.tsx b/apps/desktop/src/renderer/components/app/ProjectSelector.tsx deleted file mode 100644 index 0e175c1a..00000000 --- a/apps/desktop/src/renderer/components/app/ProjectSelector.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Folder } from "@phosphor-icons/react"; -import { useAppStore } from "../../state/appStore"; -import { Button } from "../ui/Button"; - -export function ProjectSelector() { - const project = useAppStore((s) => s.project); - const openRepo = useAppStore((s) => s.openRepo); - - return ( -
- -
-
{project?.displayName ?? "No project"}
-
{project?.rootPath ?? "Select a repo in Phase 1 onboarding"}
-
- -
- ); -} 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/automations/components/RuleEditorPanel.tsx b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx index 10235896..d5221e71 100644 --- a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx +++ b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx @@ -247,7 +247,6 @@ function builtInActionSummary(action: AutomationAction): string { if (action.type === "run-tests") return "Run test suite"; if (action.type === "run-command") return "Run shell command"; if (action.type === "predict-conflicts") return "Predict conflicts"; - if (action.type === "update-packs") return "Refresh packs"; return action.type; } @@ -730,10 +729,6 @@ export function RuleEditorPanel({ Conflict prediction - {builtInActions.length === 0 ? ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 6f0a766d..c83865fa 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -17,7 +17,6 @@ function renderComposer(overrides: Partial void; onRemoveAttachment: (path: string) => void; onSearchAttachments: (query: string) => Promise; - onContextPacksChange: (packs: ContextPackOption[]) => void; onExecutionModeChange?: (mode: AgentChatExecutionMode) => void; onPermissionModeChange?: (mode: AgentChatPermissionMode) => void; includeProjectDocs?: boolean; @@ -414,6 +410,7 @@ export function AgentChatComposer({ onComputerUsePolicyChange: (policy: ComputerUsePolicy) => void; onToggleProof?: () => void; onClearEvents?: () => void; + promptSuggestion?: string | null; subagentSnapshots?: ChatSubagentSnapshot[]; }) { const isPersistentIdentitySurface = surfaceProfile === "persistent_identity"; @@ -427,10 +424,6 @@ export function AgentChatComposer({ const [slashQuery, setSlashQuery] = useState(""); const [slashCursor, setSlashCursor] = useState(0); - const [contextPickerOpen, setContextPickerOpen] = useState(false); - const [contextPacks, setContextPacks] = useState([]); - const [contextCursor, setContextCursor] = useState(0); - const [hoveredMode, setHoveredMode] = useState(null); const [dragActive, setDragActive] = useState(false); const [computerUseModalOpen, setComputerUseModalOpen] = useState(false); @@ -510,18 +503,6 @@ export function AgentChatComposer({ return () => { cancelled = true; window.clearTimeout(timeout); }; }, [attachmentPickerOpen, attachmentQuery, attachedPaths, onSearchAttachments]); - /* ── Context pack picker ── */ - useEffect(() => { - if (!contextPickerOpen) { setContextCursor(0); return; } - if (!laneId) return; - let cancelled = false; - window.ade.agentChat - .listContextPacks({ laneId }) - .then((packs) => { if (!cancelled) { setContextPacks(packs); setContextCursor(0); } }) - .catch(() => { if (!cancelled) setContextPacks([]); }); - return () => { cancelled = true; }; - }, [contextPickerOpen, laneId]); - const selectAttachment = (attachment: AgentChatFileRef) => { onAddAttachment(attachment); setAttachmentPickerOpen(false); @@ -565,22 +546,6 @@ export function AgentChatComposer({ onDraftChange(`${cmd.command}${hint}`); }; - const packMatches = (a: ContextPackOption, b: ContextPackOption) => - a.scope === b.scope && a.featureKey === b.featureKey && a.missionId === b.missionId; - - const toggleContextPack = (pack: ContextPackOption) => { - const isSelected = selectedContextPacks.some((p) => packMatches(p, pack)); - if (isSelected) { - onContextPacksChange(selectedContextPacks.filter((p) => !packMatches(p, pack))); - } else { - onContextPacksChange([...selectedContextPacks, pack]); - } - }; - - const removeContextPack = (pack: ContextPackOption) => { - onContextPacksChange(selectedContextPacks.filter((p) => !packMatches(p, pack))); - }; - const permissionOptions = getPermissionOptions({ family: selectedModel?.family ?? sessionProvider ?? "unified", isCliWrapped: sessionIsCliWrapped ?? false, @@ -601,17 +566,6 @@ export function AgentChatComposer({ } } - /* Context picker keyboard */ - if (contextPickerOpen) { - if (event.key === "Escape") { event.preventDefault(); setContextPickerOpen(false); return; } - if (event.key === "ArrowDown") { event.preventDefault(); setContextCursor((v) => Math.min(v + 1, Math.max(contextPacks.length - 1, 0))); return; } - if (event.key === "ArrowUp") { event.preventDefault(); setContextCursor((v) => Math.max(v - 1, 0)); return; } - if (event.key === "Enter") { - const pack = contextPacks[contextCursor]; - if (pack && pack.available) { event.preventDefault(); toggleContextPack(pack); return; } - } - } - /* Trigger pickers — let "/" be typed so onChange can filter */ if (event.key === "/" && draft.length === 0 && !commandModified && !event.altKey) { setSlashPickerOpen(true); @@ -620,12 +574,6 @@ export function AgentChatComposer({ // Don't preventDefault — the "/" will appear in the textarea and onChange will // see val.startsWith("/"), keeping the picker open and enabling type-to-filter. } - if (event.key === "#" && !commandModified && !event.altKey) { - event.preventDefault(); - setContextPickerOpen(true); - setContextCursor(0); - return; - } if (event.key === "@" && !commandModified && !event.altKey) { if (!canAttach) return; event.preventDefault(); @@ -643,6 +591,13 @@ export function AgentChatComposer({ if (event.key === "." && commandModified && turnActive) { event.preventDefault(); onInterrupt(); return; } + /* Tab to accept prompt suggestion */ + if (event.key === "Tab" && !event.shiftKey && !commandModified && promptSuggestion && !draft.length && !turnActive) { + event.preventDefault(); + onDraftChange(promptSuggestion); + return; + } + if (event.key !== "Enter" || event.shiftKey) return; const commandEnter = commandModified; const shouldSend = sendOnEnter ? !commandEnter : commandEnter; @@ -703,7 +658,7 @@ export function AgentChatComposer({ ) : undefined} trays={ - attachments.length || selectedContextPacks.length || subagentSnapshots.length ? ( + attachments.length || subagentSnapshots.length ? (
{subagentSnapshots.length ? ( - {selectedContextPacks.length ? ( -
- {selectedContextPacks.map((pack) => ( - - - {pack.label} - - - ))} -
- ) : null}
) : undefined } @@ -774,51 +716,6 @@ export function AgentChatComposer({ ) : null} - {contextPickerOpen ? ( -
-
- Context Packs -
-
- {contextPacks.length ? ( - contextPacks.map((pack, index) => { - const isSelected = selectedContextPacks.some((p) => packMatches(p, pack)); - return ( - - ); - }) - ) : ( -
No context packs available.
- )} -
-
- -
-
- ) : null} - {attachmentPickerOpen ? (
@@ -1022,12 +919,6 @@ export function AgentChatComposer({ onClick={openUploadPicker} title="Upload file from disk" > -